blob: 393f57f6a4e8e30c496ed3d374ffeb84b90fa9ee [file] [log] [blame]
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2012-2014 Monty Program Ab
// Copyright (c) 2015-2021 MariaDB Corporation Ab
package org.mariadb.jdbc.client.tls;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.regex.Pattern;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.net.ssl.SSLException;
import javax.security.auth.x500.X500Principal;
import org.mariadb.jdbc.util.log.Logger;
import org.mariadb.jdbc.util.log.Loggers;
/** SSL host verification */
public class HostnameVerifier {
private static final Logger logger = Loggers.getLogger(HostnameVerifier.class);
private static final Pattern IP_V4 =
Pattern.compile(
"^(([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.)"
+ "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){2}"
+ "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$");
private static final Pattern IP_V6 = Pattern.compile("^[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){7}$");
private static final Pattern IP_V6_COMPRESSED =
Pattern.compile(
"^(([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,5})?)"
+ "::(([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,5})?)$");
/**
* DNS verification : Matching is performed using the matching rules specified by [RFC2459]. If
* more than one identity of a given type is present in the certificate (e.g., more than one
* dNSName name, a match in any one of the set is considered acceptable.) Names may contain the
* wildcard character * which is considered to match any single domain name component or component
* fragment. E.g., *.a.com matches foo.a.com but not bar.foo.a.com. f*.com matches foo.com but not
* bar.com.
*
* @param hostname hostname
* @param tlsDnsPattern DNS pattern (may contain wildcard)
* @return true if matching
*/
private static boolean matchDns(String hostname, String tlsDnsPattern) throws SSLException {
boolean hostIsIp = isIPv4(hostname) || isIPv6(hostname);
StringTokenizer hostnameSt = new StringTokenizer(hostname.toLowerCase(Locale.ROOT), ".");
StringTokenizer templateSt = new StringTokenizer(tlsDnsPattern.toLowerCase(Locale.ROOT), ".");
if (hostnameSt.countTokens() != templateSt.countTokens()) {
return false;
}
try {
while (hostnameSt.hasMoreTokens()) {
if (!matchWildCards(hostIsIp, hostnameSt.nextToken(), templateSt.nextToken())) {
return false;
}
}
} catch (SSLException exception) {
throw new SSLException(
normalizedHostMsg(hostname)
+ " doesn't correspond to certificate CN \""
+ tlsDnsPattern
+ "\" : wildcards not possible for IPs");
}
return true;
}
private static boolean matchWildCards(boolean hostIsIp, String hostnameToken, String tlsDnsToken)
throws SSLException {
int wildcardIndex = tlsDnsToken.indexOf("*");
String token = hostnameToken;
if (wildcardIndex != -1) {
if (hostIsIp) {
throw new SSLException("WildCards not possible when using IP's");
}
boolean first = true;
String beforeWildcard;
String afterWildcard = tlsDnsToken;
while (wildcardIndex != -1) {
beforeWildcard = afterWildcard.substring(0, wildcardIndex);
afterWildcard = afterWildcard.substring(wildcardIndex + 1);
int beforeStartIdx = token.indexOf(beforeWildcard);
if ((beforeStartIdx == -1) || (first && beforeStartIdx != 0)) {
return false;
}
first = false;
token = token.substring(beforeStartIdx + beforeWildcard.length());
wildcardIndex = afterWildcard.indexOf("*");
}
return token.endsWith(afterWildcard);
}
// no wildcard -> token must be equal.
return token.equals(tlsDnsToken);
}
private static String extractCommonName(String principal) throws SSLException {
if (principal == null) {
return null;
}
try {
LdapName ldapName = new LdapName(principal);
for (Rdn rdn : ldapName.getRdns()) {
if (rdn.getType().equalsIgnoreCase("CN")) {
Object obj = rdn.getValue();
if (obj != null) {
return obj.toString();
}
}
}
return null;
} catch (InvalidNameException e) {
throw new SSLException("DN value \"" + principal + "\" is invalid");
}
}
private static String normaliseAddress(String hostname) {
try {
if (hostname == null) {
return null;
}
InetAddress inetAddress = InetAddress.getByName(hostname);
return inetAddress.getHostAddress();
} catch (UnknownHostException unexpected) {
return hostname;
}
}
private static String normalizedHostMsg(String normalizedHost) {
StringBuilder msg = new StringBuilder();
if (isIPv4(normalizedHost)) {
msg.append("IPv4 host \"");
} else if (isIPv6(normalizedHost)) {
msg.append("IPv6 host \"");
} else {
msg.append("DNS host \"");
}
msg.append(normalizedHost).append("\"");
return msg.toString();
}
/**
* check if ip correspond to IPV4
*
* @param ip ip value
* @return if ip is using IPV4 format
*/
public static boolean isIPv4(final String ip) {
return IP_V4.matcher(ip).matches();
}
/**
* check if ip correspond to IPV6
*
* @param ip ip value
* @return if ip is using IPV6 format
*/
public static boolean isIPv6(final String ip) {
return IP_V6.matcher(ip).matches() || IP_V6_COMPRESSED.matcher(ip).matches();
}
private static SubjectAltNames getSubjectAltNames(X509Certificate cert)
throws CertificateParsingException {
Collection<List<?>> entries = cert.getSubjectAlternativeNames();
SubjectAltNames subjectAltNames = new SubjectAltNames();
if (entries != null) {
for (List<?> entry : entries) {
if (entry.size() >= 2) {
int type = (Integer) entry.get(0);
if (type == 2) { // DNS
String altNameDns = (String) entry.get(1);
if (altNameDns != null) {
String normalizedSubjectAlt = altNameDns.toLowerCase(Locale.ROOT);
subjectAltNames.add(new GeneralName(normalizedSubjectAlt, Extension.DNS));
}
}
if (type == 7) { // IP
String altNameIp = (String) entry.get(1);
if (altNameIp != null) {
subjectAltNames.add(new GeneralName(altNameIp, Extension.IP));
}
}
}
}
}
return subjectAltNames;
}
/**
* Verification that throw an exception with a detailed error message in case of error.
*
* @param host hostname
* @param cert certificate
* @param serverThreadId server thread Identifier to identify connection in logs
* @throws SSLException exception
*/
public static void verify(String host, X509Certificate cert, long serverThreadId)
throws SSLException {
if (host == null) {
return; // no validation if no host (possible for name pipe)
}
String lowerCaseHost = host.toLowerCase(Locale.ROOT);
try {
// ***********************************************************
// RFC 6125 : check Subject Alternative Name (SAN)
// ***********************************************************
SubjectAltNames subjectAltNames = getSubjectAltNames(cert);
if (!subjectAltNames.isEmpty()) {
// ***********************************************************
// Host is IPv4 : Check corresponding entries in subject alternative names
// ***********************************************************
if (isIPv4(lowerCaseHost)) {
for (GeneralName entry : subjectAltNames.getGeneralNames()) {
if (logger.isTraceEnabled()) {
logger.trace(
"Conn={}. IPv4 verification of hostname : type={} value={} to {}",
serverThreadId,
entry.extension,
entry.value,
lowerCaseHost);
}
if (entry.extension == Extension.IP && lowerCaseHost.equals(entry.value)) {
return;
}
}
} else if (isIPv6(lowerCaseHost)) {
// ***********************************************************
// Host is IPv6 : Check corresponding entries in subject alternative names
// ***********************************************************
String normalisedHost = normaliseAddress(lowerCaseHost);
for (GeneralName entry : subjectAltNames.getGeneralNames()) {
if (logger.isTraceEnabled()) {
logger.trace(
"Conn={}. IPv6 verification of hostname : type={} value={} to {}",
serverThreadId,
entry.extension,
entry.value,
lowerCaseHost);
}
if (entry.extension == Extension.IP
&& !isIPv4(entry.value)
&& normalisedHost.equals(normaliseAddress(entry.value))) {
return;
}
}
} else {
// ***********************************************************
// Host is not IP = DNS : Check corresponding entries in alternative subject names
// ***********************************************************
for (GeneralName entry : subjectAltNames.getGeneralNames()) {
if (logger.isTraceEnabled()) {
logger.trace(
"Conn={}. DNS verification of hostname : type={} value={} to {}",
serverThreadId,
entry.extension,
entry.value,
lowerCaseHost);
}
if (entry.extension == Extension.DNS
&& matchDns(lowerCaseHost, entry.value.toLowerCase(Locale.ROOT))) {
return;
}
}
}
}
// ***********************************************************
// RFC 2818 : legacy fallback using CN (recommendation is using alt-names)
// ***********************************************************
X500Principal subjectPrincipal = cert.getSubjectX500Principal();
String cn = extractCommonName(subjectPrincipal.getName(X500Principal.RFC2253));
if (cn == null) {
if (subjectAltNames.isEmpty()) {
throw new SSLException(
"CN not found in certificate principal \""
+ subjectPrincipal
+ "\" and certificate doesn't contain SAN");
} else {
throw new SSLException(
"CN not found in certificate principal \""
+ subjectPrincipal
+ "\" and "
+ normalizedHostMsg(lowerCaseHost)
+ " doesn't correspond to "
+ subjectAltNames);
}
}
String normalizedCn = cn.toLowerCase(Locale.ROOT);
if (logger.isTraceEnabled()) {
logger.trace(
"Conn={}. DNS verification of hostname : CN={} to {}",
serverThreadId,
normalizedCn,
lowerCaseHost);
}
if (!matchDns(lowerCaseHost, normalizedCn)) {
String errorMsg =
normalizedHostMsg(lowerCaseHost)
+ " doesn't correspond to certificate CN \""
+ normalizedCn
+ "\"";
if (!subjectAltNames.isEmpty()) {
errorMsg += " and " + subjectAltNames;
}
throw new SSLException(errorMsg);
}
} catch (CertificateParsingException cpe) {
throw new SSLException("certificate parsing error : " + cpe.getMessage());
}
}
private enum Extension {
DNS,
IP
}
private static class GeneralName {
private final String value;
private final Extension extension;
public GeneralName(String value, Extension extension) {
this.value = value;
this.extension = extension;
}
@Override
public String toString() {
return "{" + extension + ":\"" + value + "\"}";
}
}
private static class SubjectAltNames {
private final List<GeneralName> generalNames = new ArrayList<>();
@Override
public String toString() {
StringBuilder sb = new StringBuilder("SAN[");
boolean first = true;
for (GeneralName generalName : generalNames) {
if (!first) {
sb.append(",");
}
first = false;
sb.append(generalName.toString());
}
sb.append("]");
return sb.toString();
}
public List<GeneralName> getGeneralNames() {
return generalNames;
}
public void add(GeneralName generalName) {
generalNames.add(generalName);
}
public boolean isEmpty() {
return generalNames.isEmpty();
}
}
}