Introduce `PemSslStore` as an alternative to `PemSslStoreDetails`.
Add a `PemSslStore` interface that can be used as an alternative to `PemSslStoreDetails` when PEM content has already been loaded and parsed. Closes gh-38175
This commit is contained in:
parent
2b39ec6f60
commit
5e5d2265f5
|
|
@ -0,0 +1,88 @@
|
||||||
|
/*
|
||||||
|
* 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.security.PrivateKey;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link PemSslStore} loaded from {@link PemSslStoreDetails}.
|
||||||
|
*
|
||||||
|
* @author Phillip Webb
|
||||||
|
* @see PemSslStore#load(PemSslStoreDetails)
|
||||||
|
*/
|
||||||
|
final class LoadedPemSslStore implements PemSslStore {
|
||||||
|
|
||||||
|
private final PemSslStoreDetails details;
|
||||||
|
|
||||||
|
private final List<X509Certificate> certificates;
|
||||||
|
|
||||||
|
private final PrivateKey privateKey;
|
||||||
|
|
||||||
|
LoadedPemSslStore(PemSslStoreDetails details) throws IOException {
|
||||||
|
Assert.notNull(details, "Details must not be null");
|
||||||
|
this.details = details;
|
||||||
|
this.certificates = loadCertificates(details);
|
||||||
|
this.privateKey = loadPrivateKey(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<X509Certificate> loadCertificates(PemSslStoreDetails details) throws IOException {
|
||||||
|
PemContent pemContent = PemContent.load(details.certificates());
|
||||||
|
if (pemContent == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<X509Certificate> certificates = pemContent.getCertificates();
|
||||||
|
Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty");
|
||||||
|
return certificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOException {
|
||||||
|
PemContent pemContent = PemContent.load(details.privateKey());
|
||||||
|
return (pemContent != null) ? pemContent.getPrivateKey(details.privateKeyPassword()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String type() {
|
||||||
|
return this.details.type();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String alias() {
|
||||||
|
return this.details.alias();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String password() {
|
||||||
|
return this.details.password();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<X509Certificate> certificates() {
|
||||||
|
return this.certificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrivateKey privateKey() {
|
||||||
|
return this.privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
/*
|
||||||
|
* 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.security.KeyStore;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An individual trust or key store that has been loaded from PEM content.
|
||||||
|
*
|
||||||
|
* @author Phillip Webb
|
||||||
|
* @since 3.2.0
|
||||||
|
* @see PemSslStoreDetails
|
||||||
|
* @see PemContent
|
||||||
|
*/
|
||||||
|
public interface PemSslStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key store type, for example {@code JKS} or {@code PKCS11}. A {@code null} value
|
||||||
|
* will use {@link KeyStore#getDefaultType()}).
|
||||||
|
* @return the key store type
|
||||||
|
*/
|
||||||
|
String type();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alias used when setting entries in the {@link KeyStore}.
|
||||||
|
* @return the alias
|
||||||
|
*/
|
||||||
|
String alias();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the password used
|
||||||
|
* {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
|
||||||
|
* setting key entries} in the {@link KeyStore}.
|
||||||
|
* @return the password
|
||||||
|
*/
|
||||||
|
String password();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The certificates for this store. When a {@link #privateKey() private key} is
|
||||||
|
* present the returned value is treated as a certificate chain, otherwise it is
|
||||||
|
* treated a list of certificates that should all be registered.
|
||||||
|
* @return the X509 certificates
|
||||||
|
*/
|
||||||
|
List<X509Certificate> certificates();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The private key for this store or {@code null}.
|
||||||
|
* @return the private key
|
||||||
|
*/
|
||||||
|
PrivateKey privateKey();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a new {@link PemSslStore} instance with a new alias.
|
||||||
|
* @param alias the new alias
|
||||||
|
* @return a new {@link PemSslStore} instance
|
||||||
|
*/
|
||||||
|
default PemSslStore withAlias(String alias) {
|
||||||
|
return of(type(), alias, password(), certificates(), privateKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a new {@link PemSslStore} instance with a new password.
|
||||||
|
* @param password the new password
|
||||||
|
* @return a new {@link PemSslStore} instance
|
||||||
|
*/
|
||||||
|
default PemSslStore withPassword(String password) {
|
||||||
|
return of(type(), alias(), password, certificates(), privateKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a {@link PemSslStore} instance loaded using the given
|
||||||
|
* {@link PemSslStoreDetails}.
|
||||||
|
* @param details the PEM store details
|
||||||
|
* @return a loaded {@link PemSslStore} or {@code null}.
|
||||||
|
* @throws IOException on IO error
|
||||||
|
*/
|
||||||
|
static PemSslStore load(PemSslStoreDetails details) throws IOException {
|
||||||
|
if (details == null || details.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new LoadedPemSslStore(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method that can be used to create a new {@link PemSslStore} with the given
|
||||||
|
* values.
|
||||||
|
* @param type the key store type
|
||||||
|
* @param certificates the certificates for this store
|
||||||
|
* @param privateKey the private key
|
||||||
|
* @return a new {@link PemSslStore} instance
|
||||||
|
*/
|
||||||
|
static PemSslStore of(String type, List<X509Certificate> certificates, PrivateKey privateKey) {
|
||||||
|
return of(type, null, null, certificates, privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method that can be used to create a new {@link PemSslStore} with the given
|
||||||
|
* values.
|
||||||
|
* @param certificates the certificates for this store
|
||||||
|
* @param privateKey the private key
|
||||||
|
* @return a new {@link PemSslStore} instance
|
||||||
|
*/
|
||||||
|
static PemSslStore of(List<X509Certificate> certificates, PrivateKey privateKey) {
|
||||||
|
return of(null, null, null, certificates, privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method that can be used to create a new {@link PemSslStore} with the given
|
||||||
|
* values.
|
||||||
|
* @param type the key store type
|
||||||
|
* @param alias the alias used when setting entries in the {@link KeyStore}
|
||||||
|
* @param password the password used
|
||||||
|
* {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
|
||||||
|
* setting key entries} in the {@link KeyStore}
|
||||||
|
* @param certificates the certificates for this store
|
||||||
|
* @param privateKey the private key
|
||||||
|
* @return a new {@link PemSslStore} instance
|
||||||
|
*/
|
||||||
|
static PemSslStore of(String type, String alias, String password, List<X509Certificate> certificates,
|
||||||
|
PrivateKey privateKey) {
|
||||||
|
Assert.notEmpty(certificates, "Certificates must not be empty");
|
||||||
|
return new PemSslStore() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String alias() {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String password() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<X509Certificate> certificates() {
|
||||||
|
return certificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrivateKey privateKey() {
|
||||||
|
return privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package org.springframework.boot.ssl.pem;
|
package org.springframework.boot.ssl.pem;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
import java.security.KeyStore;
|
import java.security.KeyStore;
|
||||||
import java.security.KeyStoreException;
|
import java.security.KeyStoreException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
@ -27,7 +28,6 @@ import java.util.List;
|
||||||
|
|
||||||
import org.springframework.boot.ssl.SslStoreBundle;
|
import org.springframework.boot.ssl.SslStoreBundle;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
import org.springframework.util.CollectionUtils;
|
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,7 +52,7 @@ public class PemSslStoreBundle implements SslStoreBundle {
|
||||||
* @param trustStoreDetails the trust store details
|
* @param trustStoreDetails the trust store details
|
||||||
*/
|
*/
|
||||||
public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails) {
|
public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails) {
|
||||||
this(keyStoreDetails, trustStoreDetails, null);
|
this(keyStoreDetails, trustStoreDetails, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -83,8 +83,26 @@ public class PemSslStoreBundle implements SslStoreBundle {
|
||||||
|
|
||||||
private PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails, String alias,
|
private PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails, String alias,
|
||||||
boolean verifyKeys) {
|
boolean verifyKeys) {
|
||||||
this.keyStore = createKeyStore("key", keyStoreDetails, alias, verifyKeys);
|
try {
|
||||||
this.trustStore = createKeyStore("trust", trustStoreDetails, alias, verifyKeys);
|
this.keyStore = createKeyStore("key", PemSslStore.load(keyStoreDetails), alias, verifyKeys);
|
||||||
|
this.trustStore = createKeyStore("trust", PemSslStore.load(trustStoreDetails), alias, verifyKeys);
|
||||||
|
}
|
||||||
|
catch (IOException ex) {
|
||||||
|
throw new UncheckedIOException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new {@link PemSslStoreBundle} instance.
|
||||||
|
* @param pemKeyStore the PEM key store
|
||||||
|
* @param pemTrustStore the PEM trust store
|
||||||
|
* @param alias the alias to use or {@code null} to use a default alias
|
||||||
|
* @param verifyKeys whether to verify that the private key matches the public key
|
||||||
|
* @since 3.2.0
|
||||||
|
*/
|
||||||
|
public PemSslStoreBundle(PemSslStore pemKeyStore, PemSslStore pemTrustStore, String alias, boolean verifyKeys) {
|
||||||
|
this.keyStore = createKeyStore("key", pemKeyStore, alias, verifyKeys);
|
||||||
|
this.trustStore = createKeyStore("trust", pemTrustStore, alias, verifyKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -102,22 +120,22 @@ public class PemSslStoreBundle implements SslStoreBundle {
|
||||||
return this.trustStore;
|
return this.trustStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static KeyStore createKeyStore(String name, PemSslStoreDetails details, String alias, boolean verifyKeys) {
|
private static KeyStore createKeyStore(String name, PemSslStore pemSslStore, String alias, boolean verifyKeys) {
|
||||||
if (details == null || details.isEmpty()) {
|
if (pemSslStore == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Assert.notNull(details.certificate(), "Certificate content must not be null");
|
Assert.notEmpty(pemSslStore.certificates(), "Certificates must not be empty");
|
||||||
alias = (details.alias() != null) ? details.alias() : alias;
|
alias = (pemSslStore.alias() != null) ? pemSslStore.alias() : alias;
|
||||||
alias = (alias != null) ? alias : DEFAULT_ALIAS;
|
alias = (alias != null) ? alias : DEFAULT_ALIAS;
|
||||||
KeyStore store = createKeyStore(details);
|
KeyStore store = createKeyStore(pemSslStore.type());
|
||||||
X509Certificate[] certificates = loadCertificates(details);
|
List<X509Certificate> certificates = pemSslStore.certificates();
|
||||||
PrivateKey privateKey = loadPrivateKey(details);
|
PrivateKey privateKey = pemSslStore.privateKey();
|
||||||
if (privateKey != null) {
|
if (privateKey != null) {
|
||||||
if (verifyKeys) {
|
if (verifyKeys) {
|
||||||
verifyKeys(privateKey, certificates);
|
verifyKeys(privateKey, certificates);
|
||||||
}
|
}
|
||||||
addPrivateKey(store, privateKey, alias, details.password(), certificates);
|
addPrivateKey(store, privateKey, alias, pemSslStore.password(), certificates);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
addCertificates(store, certificates, alias);
|
addCertificates(store, certificates, alias);
|
||||||
|
|
@ -129,50 +147,37 @@ public class PemSslStoreBundle implements SslStoreBundle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void verifyKeys(PrivateKey privateKey, X509Certificate[] certificates) {
|
private static KeyStore createKeyStore(String type)
|
||||||
KeyVerifier keyVerifier = new KeyVerifier();
|
|
||||||
// Key should match one of the certificates
|
|
||||||
for (X509Certificate certificate : certificates) {
|
|
||||||
Result result = keyVerifier.matches(privateKey, certificate.getPublicKey());
|
|
||||||
if (result == Result.YES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalStateException("Private key matches none of the certificates");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOException {
|
|
||||||
PemContent pemContent = PemContent.load(details.privateKey());
|
|
||||||
if (pemContent == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return pemContent.getPrivateKey(details.privateKeyPassword());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static X509Certificate[] loadCertificates(PemSslStoreDetails details) throws IOException {
|
|
||||||
PemContent pemContent = PemContent.load(details.certificate());
|
|
||||||
List<X509Certificate> certificates = pemContent.getCertificates();
|
|
||||||
Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty");
|
|
||||||
return certificates.toArray(X509Certificate[]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static KeyStore createKeyStore(PemSslStoreDetails details)
|
|
||||||
throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
|
throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
|
||||||
String type = StringUtils.hasText(details.type()) ? details.type() : KeyStore.getDefaultType();
|
KeyStore store = KeyStore.getInstance(StringUtils.hasText(type) ? type : KeyStore.getDefaultType());
|
||||||
KeyStore store = KeyStore.getInstance(type);
|
|
||||||
store.load(null);
|
store.load(null);
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void addPrivateKey(KeyStore keyStore, PrivateKey privateKey, String alias, String keyPassword,
|
private static void verifyKeys(PrivateKey privateKey, List<X509Certificate> certificateChain) {
|
||||||
X509Certificate[] certificates) throws KeyStoreException {
|
KeyVerifier keyVerifier = new KeyVerifier();
|
||||||
keyStore.setKeyEntry(alias, privateKey, (keyPassword != null) ? keyPassword.toCharArray() : null, certificates);
|
// Key should match one of the certificates
|
||||||
|
for (X509Certificate certificate : certificateChain) {
|
||||||
|
KeyVerifier.Result result = keyVerifier.matches(privateKey, certificate.getPublicKey());
|
||||||
|
if (result == KeyVerifier.Result.YES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Private key matches none of the certificates in the chain");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void addCertificates(KeyStore keyStore, X509Certificate[] certificates, String alias)
|
private static void addPrivateKey(KeyStore keyStore, PrivateKey privateKey, String alias, String keyPassword,
|
||||||
|
List<X509Certificate> certificateChain) throws KeyStoreException {
|
||||||
|
keyStore.setKeyEntry(alias, privateKey, (keyPassword != null) ? keyPassword.toCharArray() : null,
|
||||||
|
certificateChain.toArray(X509Certificate[]::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addCertificates(KeyStore keyStore, List<X509Certificate> certificates, String alias)
|
||||||
throws KeyStoreException {
|
throws KeyStoreException {
|
||||||
for (int index = 0; index < certificates.length; index++) {
|
for (int index = 0; index < certificates.size(); index++) {
|
||||||
keyStore.setCertificateEntry(alias + "-" + index, certificates[index]);
|
String entryAlias = alias + ((certificates.size() == 1) ? "" : "-" + index);
|
||||||
|
X509Certificate certificate = certificates.get(index);
|
||||||
|
keyStore.setCertificateEntry(entryAlias, certificate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,19 @@ import org.springframework.util.StringUtils;
|
||||||
* @param password the password used
|
* @param password the password used
|
||||||
* {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
|
* {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
|
||||||
* setting key entries} in the {@link KeyStore}
|
* setting key entries} in the {@link KeyStore}
|
||||||
* @param certificate the certificate content (either the PEM content itself or something
|
* @param certificates the certificates content (either the PEM content itself or
|
||||||
* that can be loaded by {@link ResourceUtils#getURL})
|
* something that can be loaded by {@link ResourceUtils#getURL}). When a
|
||||||
|
* {@link #privateKey() private key} is present this value is treated as a certificate
|
||||||
|
* chain, otherwise it is treated a list of certificates that should all be registered.
|
||||||
* @param privateKey the private key content (either the PEM content itself or something
|
* @param privateKey the private key content (either the PEM content itself or something
|
||||||
* that can be loaded by {@link ResourceUtils#getURL})
|
* that can be loaded by {@link ResourceUtils#getURL})
|
||||||
* @param privateKeyPassword a password used to decrypt an encrypted private key
|
* @param privateKeyPassword a password used to decrypt an encrypted private key
|
||||||
* @author Scott Frederick
|
* @author Scott Frederick
|
||||||
* @author Phillip Webb
|
* @author Phillip Webb
|
||||||
* @since 3.1.0
|
* @since 3.1.0
|
||||||
|
* @see PemSslStore#load(PemSslStoreDetails)
|
||||||
*/
|
*/
|
||||||
public record PemSslStoreDetails(String type, String alias, String password, String certificate, String privateKey,
|
public record PemSslStoreDetails(String type, String alias, String password, String certificates, String privateKey,
|
||||||
String privateKeyPassword) {
|
String privateKeyPassword) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,7 +53,7 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
* @param password the password used
|
* @param password the password used
|
||||||
* {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
|
* {@link KeyStore#setKeyEntry(String, java.security.Key, char[], java.security.cert.Certificate[])
|
||||||
* setting key entries} in the {@link KeyStore}
|
* setting key entries} in the {@link KeyStore}
|
||||||
* @param certificate the certificate content (either the PEM content itself or
|
* @param certificates the certificate content (either the PEM content itself or
|
||||||
* something that can be loaded by {@link ResourceUtils#getURL})
|
* something that can be loaded by {@link ResourceUtils#getURL})
|
||||||
* @param privateKey the private key content (either the PEM content itself or
|
* @param privateKey the private key content (either the PEM content itself or
|
||||||
* something that can be loaded by {@link ResourceUtils#getURL})
|
* something that can be loaded by {@link ResourceUtils#getURL})
|
||||||
|
|
@ -87,6 +90,16 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
this(type, certificate, privateKey, null);
|
this(type, certificate, privateKey, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the certificate content.
|
||||||
|
* @return the certificate content
|
||||||
|
* @deprecated since 3.2.0 for removal in 3.4.0 in favor of {@link #certificates()}
|
||||||
|
*/
|
||||||
|
@Deprecated(since = "3.2.0", forRemoval = true)
|
||||||
|
public String certificate() {
|
||||||
|
return certificates();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a new {@link PemSslStoreDetails} instance with a new alias.
|
* Return a new {@link PemSslStoreDetails} instance with a new alias.
|
||||||
* @param alias the new alias
|
* @param alias the new alias
|
||||||
|
|
@ -94,7 +107,7 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
* @since 3.2.0
|
* @since 3.2.0
|
||||||
*/
|
*/
|
||||||
public PemSslStoreDetails withAlias(String alias) {
|
public PemSslStoreDetails withAlias(String alias) {
|
||||||
return new PemSslStoreDetails(this.type, alias, this.password, this.certificate, this.privateKey,
|
return new PemSslStoreDetails(this.type, alias, this.password, this.certificates, this.privateKey,
|
||||||
this.privateKeyPassword);
|
this.privateKeyPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +118,7 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
* @since 3.2.0
|
* @since 3.2.0
|
||||||
*/
|
*/
|
||||||
public PemSslStoreDetails withPassword(String password) {
|
public PemSslStoreDetails withPassword(String password) {
|
||||||
return new PemSslStoreDetails(this.type, this.alias, password, this.certificate, this.privateKey,
|
return new PemSslStoreDetails(this.type, this.alias, password, this.certificates, this.privateKey,
|
||||||
this.privateKeyPassword);
|
this.privateKeyPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +128,7 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
* @return a new {@link PemSslStoreDetails} instance
|
* @return a new {@link PemSslStoreDetails} instance
|
||||||
*/
|
*/
|
||||||
public PemSslStoreDetails withPrivateKey(String privateKey) {
|
public PemSslStoreDetails withPrivateKey(String privateKey) {
|
||||||
return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificate, privateKey,
|
return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, privateKey,
|
||||||
this.privateKeyPassword);
|
this.privateKeyPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,12 +138,12 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
* @return a new {@link PemSslStoreDetails} instance
|
* @return a new {@link PemSslStoreDetails} instance
|
||||||
*/
|
*/
|
||||||
public PemSslStoreDetails withPrivateKeyPassword(String privateKeyPassword) {
|
public PemSslStoreDetails withPrivateKeyPassword(String privateKeyPassword) {
|
||||||
return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificate, this.privateKey,
|
return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, this.privateKey,
|
||||||
privateKeyPassword);
|
privateKeyPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isEmpty() {
|
boolean isEmpty() {
|
||||||
return isEmpty(this.type) && isEmpty(this.certificate) && isEmpty(this.privateKey);
|
return isEmpty(this.type) && isEmpty(this.certificates) && isEmpty(this.privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isEmpty(String value) {
|
private boolean isEmpty(String value) {
|
||||||
|
|
@ -139,12 +152,27 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory method to create a new {@link PemSslStoreDetails} instance for the given
|
* Factory method to create a new {@link PemSslStoreDetails} instance for the given
|
||||||
* certificate.
|
* certificate. <b>Note:</b> This method doesn't actually check if the provided value
|
||||||
* @param certificate the certificate
|
* only contains a single certificate. It is functionally equivalent to
|
||||||
|
* {@link #forCertificates(String)}.
|
||||||
|
* @param certificate the certificate content (either the PEM content itself or
|
||||||
|
* something that can be loaded by {@link ResourceUtils#getURL})
|
||||||
* @return a new {@link PemSslStoreDetails} instance.
|
* @return a new {@link PemSslStoreDetails} instance.
|
||||||
*/
|
*/
|
||||||
public static PemSslStoreDetails forCertificate(String certificate) {
|
public static PemSslStoreDetails forCertificate(String certificate) {
|
||||||
return new PemSslStoreDetails(null, certificate, null);
|
return forCertificates(certificate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new {@link PemSslStoreDetails} instance for the given
|
||||||
|
* certificates.
|
||||||
|
* @param certificates the certificates content (either the PEM content itself or
|
||||||
|
* something that can be loaded by {@link ResourceUtils#getURL})
|
||||||
|
* @return a new {@link PemSslStoreDetails} instance.
|
||||||
|
* @since 3.2.0
|
||||||
|
*/
|
||||||
|
public static PemSslStoreDetails forCertificates(String certificates) {
|
||||||
|
return new PemSslStoreDetails(null, certificates, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
package org.springframework.boot.ssl.pem;
|
package org.springframework.boot.ssl.pem;
|
||||||
|
|
||||||
import java.security.KeyStore;
|
import java.security.KeyStore;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
@ -94,7 +97,7 @@ class PemSslStoreBundleTests {
|
||||||
private static final char[] EMPTY_KEY_PASSWORD = new char[] {};
|
private static final char[] EMPTY_KEY_PASSWORD = new char[] {};
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenNullStores() {
|
void createWithDetailsWhenNullStores() {
|
||||||
PemSslStoreDetails keyStoreDetails = null;
|
PemSslStoreDetails keyStoreDetails = null;
|
||||||
PemSslStoreDetails trustStoreDetails = null;
|
PemSslStoreDetails trustStoreDetails = null;
|
||||||
PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
|
PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
|
||||||
|
|
@ -104,7 +107,7 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenStoresHaveNoValues() {
|
void createWithDetailsWhenStoresHaveNoValues() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(null);
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(null);
|
||||||
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(null);
|
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(null);
|
||||||
PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
|
PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
|
||||||
|
|
@ -114,7 +117,7 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasKeyStoreDetailsCertAndKey() {
|
void createWithDetailsWhenHasKeyStoreDetailsCertAndKey() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
.withPrivateKey("classpath:test-key.pem");
|
.withPrivateKey("classpath:test-key.pem");
|
||||||
PemSslStoreDetails trustStoreDetails = null;
|
PemSslStoreDetails trustStoreDetails = null;
|
||||||
|
|
@ -124,7 +127,7 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasKeyStoreDetailsCertAndEncryptedKey() {
|
void createWithDetailsWhenHasKeyStoreDetailsCertAndEncryptedKey() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
.withPrivateKey("classpath:ssl/pkcs8/key-rsa-encrypted.pem")
|
.withPrivateKey("classpath:ssl/pkcs8/key-rsa-encrypted.pem")
|
||||||
.withPrivateKeyPassword("test");
|
.withPrivateKeyPassword("test");
|
||||||
|
|
@ -135,17 +138,17 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() {
|
void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
.withPrivateKey("classpath:test-key.pem");
|
.withPrivateKey("classpath:test-key.pem");
|
||||||
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem");
|
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem");
|
||||||
PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
|
PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails);
|
||||||
assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl"));
|
assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl"));
|
||||||
assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl-0"));
|
assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasKeyStoreDetailsAndTrustStoreDetails() {
|
void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetails() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
.withPrivateKey("classpath:test-key.pem");
|
.withPrivateKey("classpath:test-key.pem");
|
||||||
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
|
|
@ -156,7 +159,7 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasEmbeddedKeyStoreDetailsAndTrustStoreDetails() {
|
void createWithDetailsWhenHasEmbeddedKeyStoreDetailsAndTrustStoreDetails() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE).withPrivateKey(PRIVATE_KEY);
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE).withPrivateKey(PRIVATE_KEY);
|
||||||
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE)
|
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(CERTIFICATE)
|
||||||
.withPrivateKey(PRIVATE_KEY);
|
.withPrivateKey(PRIVATE_KEY);
|
||||||
|
|
@ -167,7 +170,7 @@ class PemSslStoreBundleTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("removal")
|
@SuppressWarnings("removal")
|
||||||
void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() {
|
void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
.withPrivateKey("classpath:test-key.pem");
|
.withPrivateKey("classpath:test-key.pem");
|
||||||
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
|
|
@ -178,7 +181,7 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasStoreType() {
|
void createWithDetailsWhenHasStoreType() {
|
||||||
PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem",
|
PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem",
|
||||||
"classpath:test-key.pem");
|
"classpath:test-key.pem");
|
||||||
PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem",
|
PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem",
|
||||||
|
|
@ -189,7 +192,7 @@ class PemSslStoreBundleTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void whenHasKeyStoreDetailsAndTrustStoreDetailsAndKeyPassword() {
|
void createWithDetailsWhenHasKeyStoreDetailsAndTrustStoreDetailsAndKeyPassword() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem")
|
||||||
.withPrivateKey("classpath:test-key.pem")
|
.withPrivateKey("classpath:test-key.pem")
|
||||||
.withAlias("ksa")
|
.withAlias("ksa")
|
||||||
|
|
@ -217,7 +220,7 @@ class PemSslStoreBundleTests {
|
||||||
@Test
|
@Test
|
||||||
void shouldVerifyKeysIfEnabledAndCertificateChainIsUsed() {
|
void shouldVerifyKeysIfEnabledAndCertificateChainIsUsed() {
|
||||||
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails
|
PemSslStoreDetails keyStoreDetails = PemSslStoreDetails
|
||||||
.forCertificate("classpath:org/springframework/boot/ssl/pem/key2-chain.crt")
|
.forCertificates("classpath:org/springframework/boot/ssl/pem/key2-chain.crt")
|
||||||
.withPrivateKey("classpath:org/springframework/boot/ssl/pem/key2.pem")
|
.withPrivateKey("classpath:org/springframework/boot/ssl/pem/key2.pem")
|
||||||
.withAlias("test-alias")
|
.withAlias("test-alias")
|
||||||
.withPassword("keysecret");
|
.withPassword("keysecret");
|
||||||
|
|
@ -234,6 +237,16 @@ class PemSslStoreBundleTests {
|
||||||
.withMessageContaining("Private key matches none of the certificates");
|
.withMessageContaining("Private key matches none of the certificates");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createWithPemSslStoreCreatesInstance() {
|
||||||
|
List<X509Certificate> certificates = PemContent.of(CERTIFICATE).getCertificates();
|
||||||
|
PrivateKey privateKey = PemContent.of(PRIVATE_KEY).getPrivateKey();
|
||||||
|
PemSslStore pemSslStore = PemSslStore.of(certificates, privateKey);
|
||||||
|
PemSslStoreBundle bundle = new PemSslStoreBundle(pemSslStore, pemSslStore, null, false);
|
||||||
|
assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl"));
|
||||||
|
assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl"));
|
||||||
|
}
|
||||||
|
|
||||||
private Consumer<KeyStore> storeContainingCert(String keyAlias) {
|
private Consumer<KeyStore> storeContainingCert(String keyAlias) {
|
||||||
return storeContainingCert(KeyStore.getDefaultType(), keyAlias);
|
return storeContainingCert(KeyStore.getDefaultType(), keyAlias);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* 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.security.PrivateKey;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link PemSslStore}.
|
||||||
|
*
|
||||||
|
* @author Phillip Webb
|
||||||
|
*/
|
||||||
|
class PemSslStoreTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withAliasReturnsStoreWithNewAlias() {
|
||||||
|
List<X509Certificate> certificates = List.of(mock(X509Certificate.class));
|
||||||
|
PrivateKey privateKey = mock(PrivateKey.class);
|
||||||
|
PemSslStore store = PemSslStore.of("type", "alias", "secret", certificates, privateKey);
|
||||||
|
assertThat(store.withAlias("newalias").alias()).isEqualTo("newalias");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withPasswordReturnsStoreWithNewPassword() {
|
||||||
|
List<X509Certificate> certificates = List.of(mock(X509Certificate.class));
|
||||||
|
PrivateKey privateKey = mock(PrivateKey.class);
|
||||||
|
PemSslStore store = PemSslStore.of("type", "alias", "secret", certificates, privateKey);
|
||||||
|
assertThat(store.withPassword("newsecret").password()).isEqualTo("newsecret");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofWhenNullCertificatesThrowsException() {
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> PemSslStore.of(null, null, null, null, null))
|
||||||
|
.withMessage("Certificates must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofWhenEmptyCertificatesThrowsException() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> PemSslStore.of(null, null, null, Collections.emptyList(), null))
|
||||||
|
.withMessage("Certificates must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofReturnsPemSslStore() {
|
||||||
|
List<X509Certificate> certificates = List.of(mock(X509Certificate.class));
|
||||||
|
PrivateKey privateKey = mock(PrivateKey.class);
|
||||||
|
PemSslStore store = PemSslStore.of("type", "alias", "password", certificates, privateKey);
|
||||||
|
assertThat(store.type()).isEqualTo("type");
|
||||||
|
assertThat(store.alias()).isEqualTo("alias");
|
||||||
|
assertThat(store.password()).isEqualTo("password");
|
||||||
|
assertThat(store.certificates()).isEqualTo(certificates);
|
||||||
|
assertThat(store.privateKey()).isEqualTo(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue