committed by
GitHub
15 changed files with 732 additions and 27 deletions
@ -0,0 +1,86 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard 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 |
|||
* |
|||
* http://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.thingsboard.monitoring.util; |
|||
|
|||
import org.eclipse.californium.core.config.CoapConfig; |
|||
import org.eclipse.californium.elements.config.Configuration; |
|||
import org.eclipse.californium.elements.config.SystemConfig; |
|||
import org.eclipse.californium.scandium.DTLSConnector; |
|||
import org.eclipse.californium.scandium.config.DtlsConfig; |
|||
import org.eclipse.californium.scandium.config.DtlsConnectorConfig; |
|||
import org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm; |
|||
import org.eclipse.californium.scandium.dtls.cipher.CipherSuite; |
|||
import org.thingsboard.common.util.SslUtil; |
|||
|
|||
import java.security.cert.X509Certificate; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* Builds a verify-only ({@code DTLS_ROLE = CLIENT_ONLY}) DTLS client connector for probing a |
|||
* CoAPS endpoint. Trust is scoped to the JVM's default trust store (cacerts) only — a server |
|||
* whose certificate chains to a private/custom CA will fail the handshake. Hostname/wildcard |
|||
* verification is delegated to {@link WildcardAwareCertificateVerifier}. |
|||
*/ |
|||
public class DtlsClientConnectorFactory { |
|||
|
|||
// Californium only exposes named constants for SHA256_WITH_ECDSA/SHA384_WITH_ECDSA/SHA256_WITH_RSA;
|
|||
// the other RSA/ECDSA combinations below have no named constant in this version and must be
|
|||
// looked up by name instead.
|
|||
private static final List<SignatureAndHashAlgorithm> CLIENT_SIG_ALGS = List.of( |
|||
SignatureAndHashAlgorithm.SHA256_WITH_ECDSA, |
|||
SignatureAndHashAlgorithm.SHA384_WITH_ECDSA, |
|||
SignatureAndHashAlgorithm.valueOf("SHA512withECDSA"), |
|||
SignatureAndHashAlgorithm.SHA256_WITH_RSA, |
|||
SignatureAndHashAlgorithm.valueOf("SHA384withRSA"), |
|||
SignatureAndHashAlgorithm.valueOf("SHA512withRSA"), |
|||
SignatureAndHashAlgorithm.INTRINSIC_WITH_ED25519); |
|||
|
|||
// default implicitly limits a cert-verifier-only client to ECDSA suites, breaking RSA-cert servers
|
|||
private static final List<CipherSuite> CLIENT_CIPHER_SUITES = List.of( |
|||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, |
|||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, |
|||
CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, |
|||
CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, |
|||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8, |
|||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8, |
|||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CCM, |
|||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CCM); |
|||
|
|||
private static final X509Certificate[] TRUSTED_CERTIFICATES = SslUtil.getDefaultTrustedCertificates(); |
|||
|
|||
static { |
|||
SystemConfig.register(); |
|||
CoapConfig.register(); |
|||
DtlsConfig.register(); |
|||
} |
|||
|
|||
private DtlsClientConnectorFactory() { |
|||
} |
|||
|
|||
public static DTLSConnector jvmTrustedDtlsClientConnector() { |
|||
Configuration config = Configuration.getStandard(); |
|||
config.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); // no client cert required
|
|||
config.set(DtlsConfig.DTLS_USE_SERVER_NAME_INDICATION, true); // select the correct server cert
|
|||
config.set(DtlsConfig.DTLS_SIGNATURE_AND_HASH_ALGORITHMS, CLIENT_SIG_ALGS); |
|||
config.set(DtlsConfig.DTLS_CIPHER_SUITES, CLIENT_CIPHER_SUITES); |
|||
// hostname is validated by WildcardAwareCertificateVerifier, since Scandium's own
|
|||
// DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT check doesn't support wildcard SANs
|
|||
return new DTLSConnector(DtlsConnectorConfig.builder(config) |
|||
.setAdvancedCertificateVerifier(new WildcardAwareCertificateVerifier(TRUSTED_CERTIFICATES)) |
|||
.build()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,178 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard 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 |
|||
* |
|||
* http://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.thingsboard.monitoring.util; |
|||
|
|||
import org.apache.hc.client5.http.psl.PublicSuffixMatcher; |
|||
import org.apache.hc.client5.http.psl.PublicSuffixMatcherLoader; |
|||
import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; |
|||
import org.eclipse.californium.elements.util.CertPathUtil; |
|||
import org.eclipse.californium.elements.util.StringUtil; |
|||
import org.eclipse.californium.scandium.dtls.AlertMessage; |
|||
import org.eclipse.californium.scandium.dtls.CertificateMessage; |
|||
import org.eclipse.californium.scandium.dtls.CertificateType; |
|||
import org.eclipse.californium.scandium.dtls.CertificateVerificationResult; |
|||
import org.eclipse.californium.scandium.dtls.ConnectionId; |
|||
import org.eclipse.californium.scandium.dtls.HandshakeException; |
|||
import org.eclipse.californium.scandium.dtls.HandshakeResultHandler; |
|||
import org.eclipse.californium.scandium.dtls.x509.NewAdvancedCertificateVerifier; |
|||
import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; |
|||
import org.eclipse.californium.scandium.util.ServerName; |
|||
import org.eclipse.californium.scandium.util.ServerNames; |
|||
|
|||
import javax.net.ssl.SSLException; |
|||
import javax.security.auth.x500.X500Principal; |
|||
import java.net.InetAddress; |
|||
import java.net.InetSocketAddress; |
|||
import java.security.cert.CertificateParsingException; |
|||
import java.security.cert.X509Certificate; |
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* Scandium's own subject check ({@code DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT}) rejects wildcard |
|||
* certificates outright, so this verifier delegates chain-of-trust to |
|||
* {@link StaticNewAdvancedCertificateVerifier} and hostname/wildcard matching to Apache HttpClient's |
|||
* {@link DefaultHostnameVerifier}, constructed with a real {@link PublicSuffixMatcher} (the no-arg |
|||
* constructor silently disables its public-suffix protection). {@link #matchesOverlyBroadWildcard} |
|||
* covers the one gap that verifier leaves open: multi-label public suffixes like {@code *.co.uk}. |
|||
*/ |
|||
class WildcardAwareCertificateVerifier implements NewAdvancedCertificateVerifier { |
|||
|
|||
/** SAN {@code GeneralName} type for {@code dNSName}, see RFC 5280 4.2.1.6. */ |
|||
private static final int SAN_TYPE_DNS_NAME = 2; |
|||
|
|||
private static final PublicSuffixMatcher PUBLIC_SUFFIX_MATCHER = PublicSuffixMatcherLoader.getDefault(); |
|||
private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier(PUBLIC_SUFFIX_MATCHER); |
|||
|
|||
private final NewAdvancedCertificateVerifier delegate; |
|||
|
|||
WildcardAwareCertificateVerifier(X509Certificate[] trustedCertificates) { |
|||
this(StaticNewAdvancedCertificateVerifier.builder() |
|||
.setTrustedCertificates(trustedCertificates) |
|||
.build()); |
|||
} |
|||
|
|||
WildcardAwareCertificateVerifier(NewAdvancedCertificateVerifier delegate) { |
|||
this.delegate = delegate; |
|||
} |
|||
|
|||
@Override |
|||
public List<CertificateType> getSupportedCertificateTypes() { |
|||
return delegate.getSupportedCertificateTypes(); |
|||
} |
|||
|
|||
@Override |
|||
public CertificateVerificationResult verifyCertificate(ConnectionId cid, ServerNames serverNames, InetSocketAddress remotePeer, |
|||
boolean clientUsage, boolean verifySubject, boolean truncateCertificatePath, CertificateMessage message) { |
|||
CertificateVerificationResult result = delegate.verifyCertificate(cid, serverNames, remotePeer, |
|||
clientUsage, false, truncateCertificatePath, message); |
|||
if (!verifySubject || result.getException() != null |
|||
|| result.getCertificatePath() == null || result.getCertificatePath().getCertificates().isEmpty()) { |
|||
return result; |
|||
} |
|||
|
|||
X509Certificate leafCertificate = (X509Certificate) result.getCertificatePath().getCertificates().get(0); |
|||
HandshakeException mismatch = verifySubject(serverNames, remotePeer, leafCertificate); |
|||
return mismatch == null ? result : new CertificateVerificationResult(cid, mismatch, null); |
|||
} |
|||
|
|||
@Override |
|||
public List<X500Principal> getAcceptedIssuers() { |
|||
return delegate.getAcceptedIssuers(); |
|||
} |
|||
|
|||
@Override |
|||
public void setResultHandler(HandshakeResultHandler resultHandler) { |
|||
delegate.setResultHandler(resultHandler); |
|||
} |
|||
|
|||
static HandshakeException verifySubject(ServerNames serverNames, InetSocketAddress remotePeer, X509Certificate certificate) { |
|||
String destination = getDestination(serverNames, remotePeer); |
|||
if (destination == null) { |
|||
return null; |
|||
} |
|||
if (matchesOverlyBroadWildcard(certificate, destination)) { |
|||
return mismatchException(certificate, "wildcard SAN covering destination '" + destination + "' spans a public suffix"); |
|||
} |
|||
try { |
|||
HOSTNAME_VERIFIER.verify(destination, certificate); |
|||
return null; |
|||
} catch (SSLException e) { |
|||
return mismatchException(certificate, e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
// Only the SAN entry that would actually match destination is checked, so an unrelated
|
|||
// overly-broad wildcard elsewhere on a multi-domain certificate can't cause a false rejection.
|
|||
private static boolean matchesOverlyBroadWildcard(X509Certificate certificate, String destination) { |
|||
int firstDot = destination.indexOf('.'); |
|||
if (firstDot < 0) { |
|||
return false; |
|||
} |
|||
String destinationSuffix = destination.substring(firstDot + 1); |
|||
try { |
|||
Collection<List<?>> subjectAlternativeNames = certificate.getSubjectAlternativeNames(); |
|||
if (subjectAlternativeNames == null) { |
|||
return false; |
|||
} |
|||
for (List<?> entry : subjectAlternativeNames) { |
|||
if (isDnsNameEntry(entry)) { |
|||
String dnsName = (String) entry.get(1); |
|||
if (dnsName != null && dnsName.startsWith("*.") |
|||
&& dnsName.substring(2).equalsIgnoreCase(destinationSuffix) |
|||
&& PUBLIC_SUFFIX_MATCHER.matches(destinationSuffix)) { |
|||
return true; |
|||
} |
|||
} |
|||
} |
|||
} catch (CertificateParsingException e) { |
|||
return true; // fail closed: can't safely evaluate the SAN list
|
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private static boolean isDnsNameEntry(List<?> subjectAlternativeNameEntry) { |
|||
return subjectAlternativeNameEntry.size() >= 2 && Integer.valueOf(SAN_TYPE_DNS_NAME).equals(subjectAlternativeNameEntry.get(0)); |
|||
} |
|||
|
|||
private static HandshakeException mismatchException(X509Certificate certificate, String reason) { |
|||
AlertMessage alert = new AlertMessage(AlertMessage.AlertLevel.FATAL, AlertMessage.AlertDescription.BAD_CERTIFICATE); |
|||
return new HandshakeException("Certificate " + CertPathUtil.getSubjectsCn(certificate) + ": " + reason, alert); |
|||
} |
|||
|
|||
private static String getDestination(ServerNames serverNames, InetSocketAddress remotePeer) { |
|||
String hostName = remotePeer != null ? StringUtil.toHostString(remotePeer) : null; |
|||
String literalIp = null; |
|||
if (remotePeer != null) { |
|||
InetAddress address = remotePeer.getAddress(); |
|||
if (address != null) { |
|||
literalIp = address.getHostAddress(); |
|||
} |
|||
} |
|||
if (serverNames != null) { |
|||
ServerName serverName = serverNames.getServerName(ServerName.NameType.HOST_NAME); |
|||
if (serverName != null) { |
|||
hostName = serverName.getNameAsString(); |
|||
} |
|||
} |
|||
if (hostName != null && hostName.equals(literalIp)) { |
|||
// no SNI hostname was presented, only a literal IP address to check
|
|||
hostName = null; |
|||
} |
|||
return hostName != null ? hostName : literalIp; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,68 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard 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 |
|||
* |
|||
* http://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.thingsboard.monitoring.service.transport; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.CALLS_REAL_METHODS; |
|||
import static org.mockito.Mockito.mock; |
|||
|
|||
class TransportHealthCheckerTest { |
|||
|
|||
@Test |
|||
void isSessionExpired_durationDisabled_returnsFalseRegardlessOfElapsedTime() { |
|||
TransportHealthChecker<?> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS); |
|||
ReflectionTestUtils.setField(checker, "sessionDurationMs", 0L); |
|||
ReflectionTestUtils.setField(checker, "sessionStartTimeNanos", System.nanoTime() - TimeUnit.DAYS.toNanos(1)); |
|||
|
|||
assertThat(checker.isSessionExpired()).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void isSessionExpired_beforeDurationElapsed_returnsFalse() { |
|||
TransportHealthChecker<?> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS); |
|||
ReflectionTestUtils.setField(checker, "sessionDurationMs", TimeUnit.MINUTES.toMillis(5)); |
|||
checker.recordSessionStart(); |
|||
|
|||
assertThat(checker.isSessionExpired()).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void isSessionExpired_afterDurationElapsed_returnsTrue() { |
|||
TransportHealthChecker<?> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS); |
|||
ReflectionTestUtils.setField(checker, "sessionDurationMs", TimeUnit.SECONDS.toMillis(1)); |
|||
ReflectionTestUtils.setField(checker, "sessionStartTimeNanos", System.nanoTime() - TimeUnit.SECONDS.toNanos(10)); |
|||
|
|||
assertThat(checker.isSessionExpired()).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void recordSessionStart_recordsCurrentNanoTime() { |
|||
TransportHealthChecker<?> checker = mock(TransportHealthChecker.class, CALLS_REAL_METHODS); |
|||
|
|||
long before = System.nanoTime(); |
|||
checker.recordSessionStart(); |
|||
long after = System.nanoTime(); |
|||
|
|||
long recorded = (long) ReflectionTestUtils.getField(checker, "sessionStartTimeNanos"); |
|||
assertThat(recorded).isBetween(before, after); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,262 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard 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 |
|||
* |
|||
* http://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.thingsboard.monitoring.util; |
|||
|
|||
import org.bouncycastle.asn1.x500.X500Name; |
|||
import org.bouncycastle.asn1.x509.Extension; |
|||
import org.bouncycastle.asn1.x509.GeneralName; |
|||
import org.bouncycastle.asn1.x509.GeneralNames; |
|||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
|||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
|||
import org.bouncycastle.jce.provider.BouncyCastleProvider; |
|||
import org.bouncycastle.operator.ContentSigner; |
|||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
|||
import org.eclipse.californium.scandium.dtls.AlertMessage; |
|||
import org.eclipse.californium.scandium.dtls.CertificateMessage; |
|||
import org.eclipse.californium.scandium.dtls.CertificateVerificationResult; |
|||
import org.eclipse.californium.scandium.dtls.ConnectionId; |
|||
import org.eclipse.californium.scandium.dtls.HandshakeException; |
|||
import org.eclipse.californium.scandium.dtls.x509.NewAdvancedCertificateVerifier; |
|||
import org.eclipse.californium.scandium.util.ServerNames; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import javax.security.auth.x500.X500Principal; |
|||
import java.math.BigInteger; |
|||
import java.net.InetAddress; |
|||
import java.net.InetSocketAddress; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.Security; |
|||
import java.security.cert.CertPath; |
|||
import java.security.cert.CertificateFactory; |
|||
import java.security.cert.CertificateParsingException; |
|||
import java.security.cert.X509Certificate; |
|||
import java.time.Instant; |
|||
import java.util.Date; |
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyBoolean; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
class WildcardAwareCertificateVerifierTest { |
|||
|
|||
static { |
|||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { |
|||
Security.addProvider(new BouncyCastleProvider()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_sniHostnameMatchesExactCert_returnsNoMismatch() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithDnsSan("coap.example.com"); |
|||
ServerNames serverNames = ServerNames.newInstance("coap.example.com"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_sniHostnameMatchesWildcardCert_returnsNoMismatch() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithDnsSan("*.example.com"); |
|||
ServerNames serverNames = ServerNames.newInstance("coap.example.com"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_sniHostnameMismatch_returnsException() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithDnsSan("other.example.com"); |
|||
ServerNames serverNames = ServerNames.newInstance("coap.example.com"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_overlyBroadWildcard_returnsException() throws Exception { |
|||
// A cert for "*.co.uk" must never be accepted for an arbitrary "foo.co.uk" host.
|
|||
X509Certificate cert = selfSignedCertWithDnsSan("*.co.uk"); |
|||
ServerNames serverNames = ServerNames.newInstance("foo.co.uk"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_overlyBroadWildcardOnUnrelatedSan_doesNotAffectMatchingSan_returnsNoMismatch() throws Exception { |
|||
// Regression test: a multi-SAN cert (e.g. shared/CDN hosting) carrying an unrelated
|
|||
// overly-broad wildcard for a different domain must not cause a false rejection of a
|
|||
// connection that legitimately matches a different, narrow SAN entry on the same cert.
|
|||
X509Certificate cert = selfSignedCert( |
|||
new GeneralName(GeneralName.dNSName, "coap.example.com"), |
|||
new GeneralName(GeneralName.dNSName, "*.co.uk")); |
|||
ServerNames serverNames = ServerNames.newInstance("coap.example.com"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_ipLiteralTargetWithMatchingIpSan_returnsNoMismatch() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithIpSan("192.0.2.10"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(null, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_ipLiteralTargetAgainstDnsOnlyCert_returnsException() throws Exception { |
|||
// Regression test: an IP-literal coaps:// target (e.g. monitoring's check_domain_ips
|
|||
// feature) has no SNI hostname, so it must be matched against IP SANs, not DNS SANs.
|
|||
X509Certificate cert = selfSignedCertWithDnsSan("coap.example.com"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(null, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifyCertificate_delegateReportsMismatch_returnsDelegateExceptionWithoutSubjectCheck() throws Exception { |
|||
NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class); |
|||
AlertMessage alert = new AlertMessage(AlertMessage.AlertLevel.FATAL, AlertMessage.AlertDescription.BAD_CERTIFICATE); |
|||
CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, |
|||
new HandshakeException("untrusted", alert), null); |
|||
when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult); |
|||
WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate); |
|||
|
|||
CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"), |
|||
new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class)); |
|||
|
|||
assertThat(result).isSameAs(delegateResult); |
|||
} |
|||
|
|||
@Test |
|||
void verifyCertificate_verifySubjectFalse_skipsHostnameCheckEvenOnMismatch() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithDnsSan("other.example.com"); |
|||
NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class); |
|||
CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, certPath(cert), null); |
|||
when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult); |
|||
WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate); |
|||
|
|||
CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"), |
|||
new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, false, false, mock(CertificateMessage.class)); |
|||
|
|||
assertThat(result).isSameAs(delegateResult); |
|||
} |
|||
|
|||
@Test |
|||
void verifyCertificate_emptyCertPath_returnsDelegateResultUnchanged() throws Exception { |
|||
NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class); |
|||
CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, |
|||
CertificateFactory.getInstance("X.509").generateCertPath(List.of()), null); |
|||
when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult); |
|||
WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate); |
|||
|
|||
CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"), |
|||
new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class)); |
|||
|
|||
assertThat(result).isSameAs(delegateResult); |
|||
} |
|||
|
|||
@Test |
|||
void verifyCertificate_matchingHostname_returnsDelegateResultUnchanged() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithDnsSan("coap.example.com"); |
|||
NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class); |
|||
CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, certPath(cert), null); |
|||
when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult); |
|||
WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate); |
|||
|
|||
CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"), |
|||
new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class)); |
|||
|
|||
assertThat(result).isSameAs(delegateResult); |
|||
} |
|||
|
|||
@Test |
|||
void verifyCertificate_mismatchingHostname_returnsNewExceptionResult() throws Exception { |
|||
X509Certificate cert = selfSignedCertWithDnsSan("other.example.com"); |
|||
NewAdvancedCertificateVerifier delegate = mock(NewAdvancedCertificateVerifier.class); |
|||
CertificateVerificationResult delegateResult = new CertificateVerificationResult(ConnectionId.EMPTY, certPath(cert), null); |
|||
when(delegate.verifyCertificate(any(), any(), any(), anyBoolean(), eq(false), anyBoolean(), any())).thenReturn(delegateResult); |
|||
WildcardAwareCertificateVerifier verifier = new WildcardAwareCertificateVerifier(delegate); |
|||
|
|||
CertificateVerificationResult result = verifier.verifyCertificate(ConnectionId.EMPTY, ServerNames.newInstance("coap.example.com"), |
|||
new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684), true, true, false, mock(CertificateMessage.class)); |
|||
|
|||
assertThat(result).isNotSameAs(delegateResult); |
|||
assertThat(result.getException()).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
void verifySubject_certificateParsingExceptionOnSanLookup_failsClosed() throws Exception { |
|||
X509Certificate cert = mock(X509Certificate.class); |
|||
when(cert.getSubjectAlternativeNames()).thenThrow(new CertificateParsingException("malformed SAN")); |
|||
when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); |
|||
ServerNames serverNames = ServerNames.newInstance("coap.example.com"); |
|||
InetSocketAddress remotePeer = new InetSocketAddress(InetAddress.getByName("192.0.2.10"), 5684); |
|||
|
|||
HandshakeException mismatch = WildcardAwareCertificateVerifier.verifySubject(serverNames, remotePeer, cert); |
|||
|
|||
assertThat(mismatch).isNotNull(); |
|||
} |
|||
|
|||
private static CertPath certPath(X509Certificate cert) throws Exception { |
|||
return CertificateFactory.getInstance("X.509").generateCertPath(List.of(cert)); |
|||
} |
|||
|
|||
private static X509Certificate selfSignedCertWithDnsSan(String dnsName) throws Exception { |
|||
return selfSignedCert(new GeneralName(GeneralName.dNSName, dnsName)); |
|||
} |
|||
|
|||
private static X509Certificate selfSignedCertWithIpSan(String ipAddress) throws Exception { |
|||
return selfSignedCert(new GeneralName(GeneralName.iPAddress, ipAddress)); |
|||
} |
|||
|
|||
private static X509Certificate selfSignedCert(GeneralName... sanEntries) throws Exception { |
|||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); |
|||
keyPairGenerator.initialize(256); |
|||
KeyPair keyPair = keyPairGenerator.generateKeyPair(); |
|||
|
|||
X500Name subject = new X500Name("CN=test"); |
|||
Date notBefore = Date.from(Instant.now().minusSeconds(3600)); |
|||
Date notAfter = Date.from(Instant.now().plusSeconds(3600)); |
|||
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( |
|||
subject, BigInteger.ONE, notBefore, notAfter, subject, keyPair.getPublic()); |
|||
builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(sanEntries)); |
|||
|
|||
ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keyPair.getPrivate()); |
|||
return new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(builder.build(signer)); |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue