Add PemDirectorySslStoreBundle

pull/37768/head
Moritz Halbritter 1 year ago
parent 4d65fbd49c
commit 584e5b71f0

@ -0,0 +1,205 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.ssl;
import java.nio.file.Path;
import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.CertificateSelector;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreDetails;
import org.springframework.util.StringUtils;
/**
* {@link SslBundleProperties} for directories containing PEM-encoded certificates and
* private keys.
*
* @author Moritz Halbritter
* @since 3.2.0
* @see PemDirectorySslStoreBundle
*/
public class PemDirectorySslBundleProperties extends SslBundleProperties {
/**
* Directory containing the certificates and private keys.
*/
private String directory;
/**
* Password used to decrypt an encrypted private key.
*/
private String privateKeyPassword;
/**
* File extension of the certificates, e.g. '.crt'.
*/
private String certificateExtension = ".crt";
/**
* File extension of the keys, e.g. '.key'.
*/
private String keyExtension = ".key";
/**
* Certificate selection strategy.
*/
private CertificateSelection certificateSelection = CertificateSelection.NEWEST_NOT_BEFORE;
/**
* Keystore properties.
*/
private final Store keystore = new Store();
/**
* Truststore properties.
*/
private final Store truststore = new Store();
/**
* Whether to verify that the private key matches the public key in the certificate.
*/
private boolean verifyKeys = false;
public String getDirectory() {
return this.directory;
}
public void setDirectory(String directory) {
this.directory = directory;
}
public String getPrivateKeyPassword() {
return this.privateKeyPassword;
}
public void setPrivateKeyPassword(String privateKeyPassword) {
this.privateKeyPassword = privateKeyPassword;
}
public String getCertificateExtension() {
return this.certificateExtension;
}
public void setCertificateExtension(String certificateExtension) {
this.certificateExtension = certificateExtension;
}
public String getKeyExtension() {
return this.keyExtension;
}
public void setKeyExtension(String keyExtension) {
this.keyExtension = keyExtension;
}
public CertificateSelection getCertificateSelection() {
return this.certificateSelection;
}
public void setCertificateSelection(CertificateSelection certificateSelection) {
this.certificateSelection = certificateSelection;
}
public boolean isVerifyKeys() {
return this.verifyKeys;
}
public void setVerifyKeys(boolean verifyKeys) {
this.verifyKeys = verifyKeys;
}
public Store getKeystore() {
return this.keystore;
}
public Store getTruststore() {
return this.truststore;
}
void validate() {
if (!StringUtils.hasLength(this.directory)) {
throw new InvalidConfigurationPropertyValueException("spring.ssl.bundle.pemdir.*.directory", this.directory,
"Must not be empty");
}
}
PemDirectorySslStoreDetails toDetails() {
return new PemDirectorySslStoreDetails(Path.of(getDirectory()), getKeystore().getType(),
getTruststore().getType(), getPrivateKeyPassword(), getKey().getAlias(), isVerifyKeys());
}
/**
* Certificate selection strategy.
*/
public enum CertificateSelection {
/**
* Selects the certificate with the longest 'Not After' field (which is usually
* the longest usable certificate).
*/
LONGEST_LIFETIME {
@Override
CertificateSelector getCertificateSelector() {
return CertificateSelector.maximumNotAfter();
}
},
/**
* Selects the certificate with the maximum 'Not Before' field (which is usually
* the most recently created certificate).
*/
NEWEST_NOT_BEFORE {
@Override
CertificateSelector getCertificateSelector() {
return CertificateSelector.maximumNotBefore();
}
},
/**
* Selects the certificate which has been created most recently.
*/
NEWEST_FILE {
@Override
CertificateSelector getCertificateSelector() {
return CertificateSelector.newestFile();
}
};
abstract CertificateSelector getCertificateSelector();
}
/**
* Store properties.
*/
public static class Store {
/**
* Type of the store to create, e.g. JKS.
*/
private String type;
public String getType() {
return this.type;
}
public void setType(String type) {
this.type = type;
}
}
}

@ -24,15 +24,19 @@ import org.springframework.boot.ssl.SslOptions;
import org.springframework.boot.ssl.SslStoreBundle;
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
import org.springframework.boot.ssl.jks.JksSslStoreDetails;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.CertificateMatcher;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.KeyLocator;
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
/**
* {@link SslBundle} backed by {@link JksSslBundleProperties} or
* {@link PemSslBundleProperties}.
* {@link PemSslBundleProperties} or {@link PemDirectorySslBundleProperties}.
*
* @author Scott Frederick
* @author Phillip Webb
* @author Moritz Halbritter
* @since 3.1.0
*/
public final class PropertiesSslBundle implements SslBundle {
@ -106,6 +110,17 @@ public final class PropertiesSslBundle implements SslBundle {
return new PropertiesSslBundle(asSslStoreBundle(properties), properties);
}
/**
* Get an {@link SslBundle} for the given {@link PemDirectorySslBundleProperties}.
* @param properties the source properties
* @return an {@link SslBundle} instance
* @since 3.2.0
*/
public static SslBundle get(PemDirectorySslBundleProperties properties) {
properties.validate();
return new PropertiesSslBundle(asSslStoreBundle(properties), properties);
}
private static SslStoreBundle asSslStoreBundle(PemSslBundleProperties properties) {
PemSslStoreDetails keyStoreDetails = asStoreDetails(properties.getKeystore());
PemSslStoreDetails trustStoreDetails = asStoreDetails(properties.getTruststore());
@ -129,4 +144,11 @@ public final class PropertiesSslBundle implements SslBundle {
properties.getPassword());
}
private static SslStoreBundle asSslStoreBundle(PemDirectorySslBundleProperties properties) {
return new PemDirectorySslStoreBundle(properties.toDetails(),
CertificateMatcher.withExtension(properties.getCertificateExtension()),
KeyLocator.withExtension(properties.getCertificateExtension(), properties.getKeyExtension()),
properties.getCertificateSelection().getCertificateSelector());
}
}

@ -54,6 +54,11 @@ public class SslProperties {
*/
private final Map<String, JksSslBundleProperties> jks = new LinkedHashMap<>();
/**
* Directory containing PEM-encoded SSL trust material.
*/
private final Map<String, PemDirectorySslBundleProperties> pemdir = new LinkedHashMap<>();
public Map<String, PemSslBundleProperties> getPem() {
return this.pem;
}
@ -62,6 +67,10 @@ public class SslProperties {
return this.jks;
}
public Map<String, PemDirectorySslBundleProperties> getPemdir() {
return this.pemdir;
}
}
}

@ -28,6 +28,7 @@ import org.springframework.boot.ssl.SslBundleRegistry;
*
* @author Scott Frederick
* @author Phillip Webb
* @author Moritz Halbritter
*/
class SslPropertiesBundleRegistrar implements SslBundleRegistrar {
@ -41,6 +42,7 @@ class SslPropertiesBundleRegistrar implements SslBundleRegistrar {
public void registerBundles(SslBundleRegistry registry) {
registerBundles(registry, this.properties.getPem(), PropertiesSslBundle::get);
registerBundles(registry, this.properties.getJks(), PropertiesSslBundle::get);
registerBundles(registry, this.properties.getPemdir(), PropertiesSslBundle::get);
}
private <P extends SslBundleProperties> void registerBundles(SslBundleRegistry registry, Map<String, P> properties,

@ -16,13 +16,20 @@
package org.springframework.boot.autoconfigure.ssl;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.ssl.PemDirectorySslBundleProperties.CertificateSelection;
import org.springframework.boot.ssl.SslBundle;
import static org.assertj.core.api.Assertions.assertThat;
@ -99,4 +106,45 @@ class PropertiesSslBundleTests {
assertThat(trustStore.getProvider().getName()).isEqualTo("SUN");
}
@Test
void pemDirectoryPropertiesAreMappedToSslBundle(@TempDir Path tempDir) throws Exception {
storeFile("/org/springframework/boot/autoconfigure/ssl/rsa-key.pem", tempDir.resolve("1.key"));
storeFile("/org/springframework/boot/autoconfigure/ssl/rsa-cert.pem", tempDir.resolve("1.crt"));
PemDirectorySslBundleProperties properties = new PemDirectorySslBundleProperties();
properties.getKey().setAlias("alias");
properties.getKey().setPassword("secret");
properties.getOptions().setCiphers(Set.of("cipher1", "cipher2", "cipher3"));
properties.getOptions().setEnabledProtocols(Set.of("protocol1", "protocol2"));
properties.getKeystore().setType("PKCS12");
properties.getTruststore().setType("PKCS12");
properties.setVerifyKeys(true);
properties.setKeyExtension(".key");
properties.setCertificateExtension(".crt");
properties.setCertificateSelection(CertificateSelection.LONGEST_LIFETIME);
properties.setDirectory(tempDir.toAbsolutePath().toString());
SslBundle sslBundle = PropertiesSslBundle.get(properties);
assertThat(sslBundle.getKey().getAlias()).isEqualTo("alias");
assertThat(sslBundle.getKey().getPassword()).isEqualTo("secret");
assertThat(sslBundle.getOptions().getCiphers()).containsExactlyInAnyOrder("cipher1", "cipher2", "cipher3");
assertThat(sslBundle.getOptions().getEnabledProtocols()).containsExactlyInAnyOrder("protocol1", "protocol2");
assertThat(sslBundle.getStores()).isNotNull();
Certificate certificate = sslBundle.getStores().getKeyStore().getCertificate("alias");
assertThat(certificate).isNotNull();
assertThat(certificate.getType()).isEqualTo("X.509");
Key key = sslBundle.getStores().getKeyStore().getKey("alias", null);
assertThat(key).isNotNull();
assertThat(key.getAlgorithm()).isEqualTo("RSA");
certificate = sslBundle.getStores().getTrustStore().getCertificate("alias-0");
assertThat(certificate).isNotNull();
assertThat(certificate.getType()).isEqualTo("X.509");
}
private static void storeFile(String resourceName, Path file) throws IOException {
try (InputStream resourceStream = PropertiesSslBundleTests.class.getResourceAsStream(resourceName);
OutputStream fileStream = Files.newOutputStream(file)) {
assertThat(resourceStream).isNotNull();
resourceStream.transferTo(fileStream);
}
}
}

@ -0,0 +1,145 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.Certificate;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.CertificateSelector;
import org.springframework.util.Assert;
/**
* {@link CertificateSelector} implementations.
*
* @author Moritz Halbritter
*/
final class CertificateSelectors {
private CertificateSelectors() {
}
abstract static class AbstractCertificateSelector implements CertificateSelector {
private final Clock clock;
AbstractCertificateSelector() {
this(Clock.systemDefaultZone());
}
AbstractCertificateSelector(Clock clock) {
Assert.notNull(clock, "clock must not be null");
this.clock = clock;
}
@Override
public Certificate select(List<Certificate> certificates) {
Instant now = this.clock.instant();
List<Certificate> preProcessed = certificates.stream().filter((c) -> isUsable(c, now)).toList();
return doSelect(preProcessed);
}
protected abstract Certificate doSelect(List<Certificate> candidates);
private boolean isUsable(Certificate certificate, Instant now) {
return now.isAfter(certificate.certificate().getNotBefore().toInstant())
&& now.isBefore(certificate.certificate().getNotAfter().toInstant());
}
}
static class MaximumNotAfterCertificateSelector extends AbstractCertificateSelector {
MaximumNotAfterCertificateSelector() {
super();
}
MaximumNotAfterCertificateSelector(Clock clock) {
super(clock);
}
@Override
protected Certificate doSelect(List<Certificate> candidates) {
return candidates.stream().max(Comparator.comparing((c) -> c.certificate().getNotAfter())).orElse(null);
}
}
static class MaximumNotBeforeCertificateSelector extends AbstractCertificateSelector {
MaximumNotBeforeCertificateSelector() {
super();
}
MaximumNotBeforeCertificateSelector(Clock clock) {
super(clock);
}
@Override
protected Certificate doSelect(List<Certificate> candidates) {
return candidates.stream().max(Comparator.comparing((c) -> c.certificate().getNotBefore())).orElse(null);
}
}
static class NewestFileCertificateSelector extends AbstractCertificateSelector {
NewestFileCertificateSelector() {
super();
}
NewestFileCertificateSelector(Clock clock) {
super(clock);
}
@Override
protected Certificate doSelect(List<Certificate> candidates) {
if (candidates.isEmpty()) {
return null;
}
if (candidates.size() == 1) {
return candidates.get(0);
}
Certificate certificate = null;
Instant created = null;
for (Certificate candidate : candidates) {
BasicFileAttributes attributes;
try {
attributes = Files.readAttributes(candidate.file(), BasicFileAttributes.class);
}
catch (IOException ex) {
throw new UncheckedIOException("Failed to get creation time of file %s".formatted(candidate.file()),
ex);
}
Instant candidateCreationTime = attributes.creationTime().toInstant();
if (created == null || candidateCreationTime.isAfter(created)) {
certificate = candidate;
created = candidateCreationTime;
}
}
return certificate;
}
}
}

@ -0,0 +1,231 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.boot.ssl.SslStoreBundle;
import org.springframework.util.Assert;
/**
* A {@link SslStoreBundle} which uses a directory containing certificates and keys in PEM
* encoding.
*
* @author Moritz Halbritter
* @since 3.2.0
* @see PemSslStoreBundle
*/
public class PemDirectorySslStoreBundle implements SslStoreBundle {
private final SslStoreBundle delegate;
/**
* Creates a new {@link PemDirectorySslStoreBundle}.
* @param details the {@link PemDirectorySslStoreDetails} to create the bundle
* @param certificateMatcher the strategy to find certificates
* @param keyLocator the strategy to find the key for a certificate
* @param certificateSelector the strategy to select a certificate
*/
public PemDirectorySslStoreBundle(PemDirectorySslStoreDetails details, CertificateMatcher certificateMatcher,
KeyLocator keyLocator, CertificateSelector certificateSelector) {
Assert.notNull(details, "details must not be null");
Assert.notNull(certificateMatcher, "certificateMatcher must not be null");
Assert.notNull(keyLocator, "keyLocator must not be null");
Assert.notNull(certificateSelector, "certificateSelector must not be null");
List<Path> files = listFiles(details.directory());
List<Certificate> certificates = findCertificates(certificateMatcher, files);
Certificate certificate = selectCertificate(certificateSelector, certificates);
Path key = findKey(keyLocator, certificate, files);
this.delegate = loadBundle(certificate.file(), key, details);
}
@Override
public KeyStore getKeyStore() {
return this.delegate.getKeyStore();
}
@Override
public String getKeyStorePassword() {
return this.delegate.getKeyStorePassword();
}
@Override
public KeyStore getTrustStore() {
return this.delegate.getTrustStore();
}
private static PemSslStoreBundle loadBundle(Path certificate, Path key, PemDirectorySslStoreDetails details) {
String certificateContent = readContent(certificate);
String keyContent = readContent(key);
return new PemSslStoreBundle(
new PemSslStoreDetails(details.keyStoreType(), certificateContent, keyContent,
details.privateKeyPassword()),
new PemSslStoreDetails(details.trustStoreType(), certificateContent, null), details.alias(), null,
details.verifyKeys());
}
private static List<Path> listFiles(Path directory) {
try (Stream<Path> fileStream = Files.list(directory)) {
return fileStream.toList();
}
catch (IOException ex) {
throw new UncheckedIOException("Failed to list files in directory '%s'".formatted(directory), ex);
}
}
private static Certificate selectCertificate(CertificateSelector certificateSelector,
List<Certificate> certificates) {
Certificate selected = certificateSelector.select(certificates);
if (selected == null) {
throw new IllegalStateException("No certificate could be selected. Candidates: %s".formatted(certificates));
}
return selected;
}
private static Path findKey(KeyLocator keyLocator, Certificate certificate, List<Path> files) {
Path key = keyLocator.locate(certificate, files);
if (key == null || !Files.exists(key)) {
throw new IllegalStateException("Key for certificate '%s' not found".formatted(certificate.file()));
}
return key;
}
private static List<Certificate> findCertificates(CertificateMatcher certificateMatcher, List<Path> files) {
List<Certificate> candidates = new ArrayList<>();
for (Path file : files) {
if (certificateMatcher.matches(file)) {
String content = readContent(file);
X509Certificate[] x509Certificates = PemCertificateParser.parse(content);
if (x509Certificates == null || x509Certificates.length == 0) {
throw new IllegalStateException("No certificates found in file '%s'".formatted(file));
}
// Always use the first certificate if it is a chain
candidates.add(new Certificate(file, x509Certificates[0]));
}
}
return candidates;
}
private static String readContent(Path file) {
try {
return Files.readString(file);
}
catch (IOException ex) {
throw new UncheckedIOException("Failed to read content of file '%s'".formatted(file), ex);
}
}
/**
* Certificate.
*
* @param file the certificate file
* @param certificate the parsed certificate
*/
public record Certificate(Path file, X509Certificate certificate) {
}
public interface KeyLocator {
/**
* Locates the key belonging to the given {@code certificate}.
* @param certificate the certificate to locate a key for
* @param files the available files
* @return the path to the key, or {@code null}
*/
Path locate(Certificate certificate, Collection<Path> files);
/**
* Creates a {@link KeyLocator} which selects keys based on the file extension.
* @param certificateExtension the extension of certificate files
* @param keyExtension the extension of key files
* @return the key locator
*/
static KeyLocator withExtension(String certificateExtension, String keyExtension) {
return new SuffixKeyLocator(certificateExtension, keyExtension);
}
}
public interface CertificateMatcher {
/**
* Decides whether the given {@code file} is a certificate.
* @param file the file to decide on
* @return whether the file is a certificate
*/
boolean matches(Path file);
/**
* Creates a {@link CertificateMatcher} which selects certificates based on the
* file extension.
* @param certificateExtension the extension of certificate files
* @return the certificate matcher
*/
static CertificateMatcher withExtension(String certificateExtension) {
return new SuffixCertificateMatcher(certificateExtension);
}
}
public interface CertificateSelector {
/**
* Selects a certificate from the given {@code certificates}.
* @param certificates the certificates
* @return the selected certificate
*/
Certificate select(List<Certificate> certificates);
/**
* Creates a {@link CertificateSelector} which selects the certificate with the
* maximum not after field.
* @return the certificate selector
*/
static CertificateSelector maximumNotAfter() {
return new CertificateSelectors.MaximumNotAfterCertificateSelector();
}
/**
* Creates a {@link CertificateSelector} which selects the certificate with the
* maximum not before field.
* @return the certificate selector
*/
static CertificateSelector maximumNotBefore() {
return new CertificateSelectors.MaximumNotBeforeCertificateSelector();
}
/**
* Creates a {@link CertificateSelector} which selects the certificate with the
* newest file creation date.
* @return the certificate selector
*/
static CertificateSelector newestFile() {
return new CertificateSelectors.NewestFileCertificateSelector();
}
}
}

@ -0,0 +1,40 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import org.springframework.util.Assert;
/**
* Details to create a {@link PemDirectorySslStoreBundle}.
*
* @param directory the directory to load the certificate and key from
* @param keyStoreType the type of keystore or {@code null}
* @param trustStoreType the type of truststore or {@code null}
* @param privateKeyPassword the password for loading the private key or {@code null}
* @param alias the alias for the certificate and key or {@code null}
* @param verifyKeys whether to verify the certificate and key
* @author Moritz Halbritter
* @since 3.2.0
*/
public record PemDirectorySslStoreDetails(Path directory, String keyStoreType, String trustStoreType,
String privateKeyPassword, String alias, boolean verifyKeys) {
public PemDirectorySslStoreDetails {
Assert.notNull(directory, "directory must not be null");
}
}

@ -0,0 +1,43 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.CertificateMatcher;
import org.springframework.util.Assert;
/**
* A {@link CertificateMatcher} which matches files with a given suffix.
*
* @author Moritz Halbritter
*/
class SuffixCertificateMatcher implements CertificateMatcher {
private final String certificateSuffix;
SuffixCertificateMatcher(String certificateSuffix) {
Assert.notNull(certificateSuffix, "certificateSuffix must not be null");
this.certificateSuffix = certificateSuffix;
}
@Override
public boolean matches(Path file) {
return file.toString().endsWith(this.certificateSuffix);
}
}

@ -0,0 +1,56 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import java.util.Collection;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.Certificate;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.KeyLocator;
import org.springframework.util.Assert;
/**
* A {@link KeyLocator} which matches files with a given suffix.
*
* @author Moritz Halbritter
*/
class SuffixKeyLocator implements KeyLocator {
private final String certificateSuffix;
private final String keySuffix;
SuffixKeyLocator(String certificateSuffix, String keySuffix) {
Assert.notNull(certificateSuffix, "certificateSuffix must not be null");
Assert.notNull(keySuffix, "keySuffix must not be null");
this.certificateSuffix = certificateSuffix;
this.keySuffix = keySuffix;
}
@Override
public Path locate(Certificate certificate, Collection<Path> files) {
String path = certificate.file().toString();
if (!path.endsWith(this.certificateSuffix)) {
throw new IllegalArgumentException(
"Path '%s' does not end with '%s'".formatted(path, this.certificateSuffix));
}
path = path.substring(0, path.length() - this.certificateSuffix.length());
path = path + this.keySuffix;
return Path.of(path);
}
}

@ -0,0 +1,73 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.sql.Date;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.Certificate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
/**
* Tests for {@link CertificateSelectors.MaximumNotAfterCertificateSelector}.
*
* @author Moritz Halbritter
*/
class MaximumNotAfterCertificateSelectorTests {
private static final Instant NOW = Instant.parse("2000-01-01T00:00:00Z");
private final CertificateSelectors.MaximumNotAfterCertificateSelector selector = new CertificateSelectors.MaximumNotAfterCertificateSelector(
Clock.fixed(NOW, ZoneId.of("UTC")));
@Test
void shouldSelectCertificateWithMaximumNotAfter() {
// Valid until 10s
Certificate cert1 = new Certificate(Path.of("certificate-1"),
createCertificate(NOW.minusSeconds(10), NOW.plusSeconds(10)));
// Not valid, starts in the future
Certificate cert2 = new Certificate(Path.of("certificate-2"),
createCertificate(NOW.plusSeconds(1), NOW.plusSeconds(15)));
// Not valid, expired
Certificate cert3 = new Certificate(Path.of("certificate-3"),
createCertificate(NOW.minusSeconds(10), NOW.minusSeconds(1)));
// Valid until 20s
Certificate cert4 = new Certificate(Path.of("certificate-4"),
createCertificate(NOW.minusSeconds(10), NOW.plusSeconds(20)));
List<Certificate> candidates = List.of(cert1, cert2, cert3, cert4);
Certificate selected = this.selector.select(candidates);
assertThat(selected).isEqualTo(cert4);
}
private X509Certificate createCertificate(Instant notBefore, Instant notAfter) {
X509Certificate certificate = Mockito.mock(X509Certificate.class);
given(certificate.getNotBefore()).willReturn(Date.from(notBefore));
given(certificate.getNotAfter()).willReturn(Date.from(notAfter));
return certificate;
}
}

@ -0,0 +1,73 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.sql.Date;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.Certificate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
/**
* Tests for {@link CertificateSelectors.MaximumNotBeforeCertificateSelector}.
*
* @author Moritz Halbritter
*/
class MaximumNotBeforeCertificateSelectorTests {
private static final Instant NOW = Instant.parse("2000-01-01T00:00:00Z");
private final CertificateSelectors.MaximumNotBeforeCertificateSelector selector = new CertificateSelectors.MaximumNotBeforeCertificateSelector(
Clock.fixed(NOW, ZoneId.of("UTC")));
@Test
void shouldSelectCertificateWithMaximumNotBefore() {
// Valid since -10s
Certificate cert1 = new Certificate(Path.of("certificate-1"),
createCertificate(NOW.minusSeconds(10), NOW.plusSeconds(1)));
// Not valid, starts in the future
Certificate cert2 = new Certificate(Path.of("certificate-2"),
createCertificate(NOW.plusSeconds(1), NOW.plusSeconds(15)));
// Not valid, expired
Certificate cert3 = new Certificate(Path.of("certificate-3"),
createCertificate(NOW.minusSeconds(10), NOW.minusSeconds(1)));
// Valid since -20s
Certificate cert4 = new Certificate(Path.of("certificate-4"),
createCertificate(NOW.minusSeconds(20), NOW.plusSeconds(1)));
List<Certificate> candidates = List.of(cert1, cert2, cert3, cert4);
Certificate selected = this.selector.select(candidates);
assertThat(selected).isEqualTo(cert1);
}
private X509Certificate createCertificate(Instant notBefore, Instant notAfter) {
X509Certificate certificate = Mockito.mock(X509Certificate.class);
given(certificate.getNotBefore()).willReturn(Date.from(notBefore));
given(certificate.getNotAfter()).willReturn(Date.from(notAfter));
return certificate;
}
}

@ -0,0 +1,81 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.sql.Date;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.Certificate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
/**
* Tests for {@link CertificateSelectors.NewestFileCertificateSelector}.
*
* @author Moritz Halbritter
*/
class NewestFileCertificateSelectorTests {
private static final Instant NOW = Instant.parse("2000-01-01T00:00:00Z");
private final CertificateSelectors.NewestFileCertificateSelector selector = new CertificateSelectors.NewestFileCertificateSelector(
Clock.fixed(NOW, ZoneId.of("UTC")));
@Test
void shouldSelectNewestFile(@TempDir Path tempDirectory) throws IOException, InterruptedException {
// There's no portable way to set the file creation date, so we resort to sleeps
// here
Path cert1file = Files.createFile(tempDirectory.resolve("certificate-1"));
Thread.sleep(10);
Path cert2file = Files.createFile(tempDirectory.resolve("certificate-2"));
Thread.sleep(10);
Path cert3file = Files.createFile(tempDirectory.resolve("certificate-3"));
Thread.sleep(10);
Path cert4file = Files.createFile(tempDirectory.resolve("certificate-4"));
// Valid
Certificate cert1 = createCertificate(cert1file, NOW.minusSeconds(10), NOW.plusSeconds(10));
// Not valid, starts in the future
Certificate cert2 = createCertificate(cert2file, NOW.plusSeconds(1), NOW.plusSeconds(15));
// Not valid, expired
Certificate cert3 = createCertificate(cert3file, NOW.minusSeconds(1), NOW.minusSeconds(1));
// Valid
Certificate cert4 = createCertificate(cert4file, NOW.minusSeconds(1), NOW.plusSeconds(1));
List<Certificate> candidates = List.of(cert1, cert2, cert3, cert4);
Certificate selected = this.selector.select(candidates);
assertThat(selected).isEqualTo(cert4);
}
private Certificate createCertificate(Path file, Instant notBefore, Instant notAfter) {
X509Certificate certificate = Mockito.mock(X509Certificate.class);
given(certificate.getNotBefore()).willReturn(Date.from(notBefore));
given(certificate.getNotAfter()).willReturn(Date.from(notAfter));
return new Certificate(file, certificate);
}
}

@ -0,0 +1,133 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.CertificateMatcher;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.CertificateSelector;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.KeyLocator;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@link PemDirectorySslStoreBundle}.
*
* @author Moritz Halbritter
*/
class PemDirectorySslStoreBundleTests {
private static final Instant NOW = Instant.parse("2023-12-31T00:00:00Z");
private final CertificateMatcher certificateMatcher = new SuffixCertificateMatcher(".crt");
private final KeyLocator keyLocator = new SuffixKeyLocator(".crt", ".key");
private final CertificateSelector certificateSelector = new CertificateSelectors.MaximumNotAfterCertificateSelector(
Clock.fixed(NOW, ZoneId.of("UTC")));
@Test
void shouldLoadFromDirectory(@TempDir Path tempDir)
throws IOException, UnrecoverableKeyException, KeyStoreException, NoSuchAlgorithmException {
storeFile(tempDir, "key1.crt", "1.crt");
storeFile(tempDir, "key1.pem", "1.key");
storeFile(tempDir, "key2.crt", "2.crt");
storeFile(tempDir, "key2.pem", "2.key");
PemDirectorySslStoreBundle bundle = createBundle(tempDir);
assertHasKey(bundle.getKeyStore(), "alias");
// This is the serial number of key2.crt
assertHasCertificate(bundle.getKeyStore(), "alias", "506931258775469093758931777760969589521779563942");
assertHasCertificate(bundle.getTrustStore(), "alias-0", "506931258775469093758931777760969589521779563942");
}
@Test
void shouldFailIfGivenNonExistentDirectory() {
assertThatThrownBy(() -> createBundle(Path.of("/this/path/does/not/exist")))
.isInstanceOf(UncheckedIOException.class)
.hasMessage("Failed to list files in directory '/this/path/does/not/exist'");
}
@Test
void shouldFailIfGivenEmptyDirectory(@TempDir Path tempDir) {
assertThatIllegalStateException().isThrownBy(() -> createBundle(tempDir))
.withMessage("No certificate could be selected. Candidates: []");
}
@Test
void shouldFailIfKeyIsMissing(@TempDir Path tempDir) throws IOException {
storeFile(tempDir, "key1.crt", "1.crt");
storeFile(tempDir, "key2.crt", "2.crt");
assertThatIllegalStateException().isThrownBy(() -> createBundle(tempDir))
.withMessageMatching("Key for certificate '.+' not found");
}
@Test
void shouldFailIfNoCertificatesCanBeLoaded(@TempDir Path tempDir) throws IOException {
Files.createFile(tempDir.resolve("1.crt"));
assertThatIllegalStateException().isThrownBy(() -> createBundle(tempDir))
.withMessageMatching("No certificates found in file '.+'");
}
private PemDirectorySslStoreBundle createBundle(Path tempDir) {
return new PemDirectorySslStoreBundle(
new PemDirectorySslStoreDetails(tempDir, null, null, null, "alias", false), this.certificateMatcher,
this.keyLocator, this.certificateSelector);
}
private void assertHasCertificate(KeyStore keyStore, String alias, String serialNumber) throws KeyStoreException {
Certificate certificate = keyStore.getCertificate(alias);
assertThat(certificate).as("certificate").isNotNull();
assertThat(certificate).isInstanceOf(X509Certificate.class);
X509Certificate x509Certificate = (X509Certificate) certificate;
assertThat(x509Certificate.getSerialNumber()).isEqualTo(serialNumber);
}
private static void storeFile(Path tempDir, String resourceName, String fileName) throws IOException {
try (InputStream resourceStream = PemDirectorySslStoreBundleTests.class.getResourceAsStream(resourceName);
OutputStream fileStream = Files.newOutputStream(tempDir.resolve(fileName))) {
assertThat(resourceStream).isNotNull();
resourceStream.transferTo(fileStream);
}
}
private static void assertHasKey(KeyStore keyStore, String alias)
throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
Key key = keyStore.getKey(alias, null);
assertThat(key).as("key").isNotNull();
}
}

@ -0,0 +1,46 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link SuffixCertificateMatcher}.
*
* @author Moritz Halbritter
*/
class SuffixCertificateMatcherTests {
private final SuffixCertificateMatcher predicate = new SuffixCertificateMatcher(".crt");
@Test
void shouldMatch() {
assertThat(this.predicate.matches(Path.of("1.crt"))).isTrue();
assertThat(this.predicate.matches(Path.of(".crt"))).isTrue();
}
@Test
void shouldNotMatch() {
assertThat(this.predicate.matches(Path.of(".key"))).isFalse();
assertThat(this.predicate.matches(Path.of(".pem"))).isFalse();
}
}

@ -0,0 +1,59 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.ssl.pem;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.ssl.pem.PemDirectorySslStoreBundle.Certificate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link SuffixKeyLocator}.
*
* @author Moritz Halbritter
*/
class SuffixKeyLocatorTests {
private final SuffixKeyLocator locator = new SuffixKeyLocator(".crt", ".key");
@Test
void shouldMatch() {
Certificate certificate = createCertificate("1.crt");
Path key = this.locator.locate(certificate, Set.of(Path.of("1.crt"), Path.of("1.key")));
assertThat(key).isEqualTo(Path.of("1.key"));
}
@Test
void shouldThrowExceptionIfGivenCertificateDoesntEndWithSuffix() {
Certificate certificate = createCertificate("1.pem");
assertThatIllegalArgumentException().isThrownBy(() -> this.locator.locate(certificate, Collections.emptySet()))
.withMessageContaining("does not end with '.crt'");
}
private Certificate createCertificate(String file) {
return new Certificate(Path.of(file), Mockito.mock(X509Certificate.class));
}
}
Loading…
Cancel
Save