/* See LICENSE for licensing and NOTICE for copyright. */
package org.ldaptive.ssl;
import java.nio.ByteBuffer;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import org.ldaptive.LdapUtils;
import org.ldaptive.asn1.DN;
import org.ldaptive.asn1.RDN;
import org.ldaptive.io.StringValueTranscoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Hostname verifier that provides an implementation similar to what occurs with JNDI startTLS. Verification occurs in
* the following order:
*
* <ul>
* <li>if hostname is IP, then cert must have exact match IP subjAltName</li>
* <li>hostname must match any DNS subjAltName if any exist</li>
* <li>hostname must match the first CN</li>
* <li>if cert begins with a wildcard, domains are used for matching</li>
* </ul>
*
* @author Middleware Services
*/
public class DefaultHostnameVerifier implements HostnameVerifier, CertificateHostnameVerifier
{
/** Enum for subject alt name types. */
private enum SubjectAltNameType {
/** other name (0). */
OTHER_NAME,
/** ref822 name (1). */
RFC822_NAME,
/** dns name (2). */
DNS_NAME,
/** x400 address (3). */
X400_ADDRESS,
/** directory name (4). */
DIRECTORY_NAME,
/** edi party name (5). */
EDI_PARTY_NAME,
/** uniform resource identifier (6). */
UNIFORM_RESOURCE_IDENTIFIER,
/** ip address (7). */
IP_ADDRESS,
/** registered id (8). */
REGISTERED_ID
}
/** Logger for this class. */
protected final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public boolean verify(final String hostname, final SSLSession session)
{
boolean b = false;
try {
String name = null;
if (hostname != null) {
// if IPv6 strip off the "[]"
if (hostname.startsWith("[") && hostname.endsWith("]")) {
name = hostname.substring(1, hostname.length() - 1).trim();
} else {
name = hostname.trim();
}
}
b = verify(name, (X509Certificate) session.getPeerCertificates()[0]);
} catch (SSLPeerUnverifiedException e) {
logger.warn("Could not get certificate from the SSL session", e);
}
return b;
}
/**
* Verify if the hostname is an IP address using {@link LdapUtils#isIPAddress(String)}. Delegates to {@link
* #verifyIP(String, X509Certificate)} and {@link #verifyDNS(String, X509Certificate)} accordingly.
*
* @param hostname to verify
* @param cert to verify hostname against
*
* @return whether hostname is valid for the supplied certificate
*/
@Override
public boolean verify(final String hostname, final X509Certificate cert)
{
logger.debug("verifying hostname={} against cert={}", hostname, cert.getSubjectX500Principal());
boolean b;
if (LdapUtils.isIPAddress(hostname)) {
b = verifyIP(hostname, cert);
} else {
b = verifyDNS(hostname, cert);
}
return b;
}
/**
* Verify the certificate allows use of the supplied IP address.
*
* <p>From RFC2818: In some cases, the URI is specified as an IP address rather than a hostname. In this case, the
* iPAddress subjectAltName must be present in the certificate and must exactly match the IP in the URI.</p>
*
* @param ip address to match in the certificate
* @param cert to inspect for the IP address
*
* @return whether the ip matched a subject alt name
*/
protected boolean verifyIP(final String ip, final X509Certificate cert)
{
final String[] subjAltNames = getSubjectAltNames(cert, SubjectAltNameType.IP_ADDRESS);
logger.debug("verifyIP using subjectAltNames={}", Arrays.toString(subjAltNames));
for (String name : subjAltNames) {
if (ip.equalsIgnoreCase(name)) {
logger.debug("verifyIP found hostname match: {}", name);
return true;
}
}
return false;
}
/**
* Verify the certificate allows use of the supplied DNS name. Note that only the first CN is used.
*
* <p>From RFC2818: If a subjectAltName extension of type dNSName is present, that MUST be used as the identity.
* Otherwise, the (most specific) Common Name field in the Subject field of the certificate MUST be used. Although the
* use of the Common Name is existing practice, it is deprecated and Certification Authorities are encouraged to use
* the dNSName instead.</p>
*
* <p>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.)</p>
*
* @param hostname to match in the certificate
* @param cert to inspect for the hostname
*
* @return whether the hostname matched a subject alt name or CN
*/
protected boolean verifyDNS(final String hostname, final X509Certificate cert)
{
boolean verified = false;
final String[] subjAltNames = getSubjectAltNames(cert, SubjectAltNameType.DNS_NAME);
logger.debug("verifyDNS using subjectAltNames={}", Arrays.toString(subjAltNames));
if (subjAltNames.length > 0) {
// if subject alt names exist, one must match
for (String name : subjAltNames) {
if (isMatch(hostname, name)) {
logger.debug("verifyDNS found hostname match: {}", name);
verified = true;
break;
}
}
} else {
final String[] cns = getCNs(cert);
logger.debug("verifyDNS using CN={}", Arrays.toString(cns));
if (cns.length > 0) {
// the most specific CN refers to the last CN
if (isMatch(hostname, cns[cns.length - 1])) {
logger.debug("verifyDNS found hostname match: {}", cns[cns.length - 1]);
verified = true;
}
}
}
return verified;
}
/**
* Returns the subject alternative names matching the supplied name type from the supplied certificate.
*
* @param cert to get subject alt names from
* @param type subject alt name type
*
* @return subject alt names
*/
private String[] getSubjectAltNames(final X509Certificate cert, final SubjectAltNameType type)
{
final List<String> names = new ArrayList<>();
try {
final Collection<List<?>> subjAltNames = cert.getSubjectAlternativeNames();
if (subjAltNames != null) {
for (List<?> generalName : subjAltNames) {
final Integer nameType = (Integer) generalName.get(0);
if (nameType == type.ordinal()) {
names.add((String) generalName.get(1));
}
}
}
} catch (CertificateParsingException e) {
logger.warn("Error reading subject alt names from certificate", e);
}
return names.toArray(new String[names.size()]);
}
/**
* Returns the CNs from the supplied certificate.
*
* @param cert to get CNs from
*
* @return CNs
*/
private String[] getCNs(final X509Certificate cert)
{
final List<String> names = new ArrayList<>();
final byte[] encodedDn = cert.getSubjectX500Principal().getEncoded();
if (encodedDn != null && encodedDn.length > 0) {
final DN dn = DN.decode(ByteBuffer.wrap(encodedDn));
for (RDN rdn : dn.getRDNs()) {
// for multi value RDNs the first value is used
final String value = rdn.getAttributeValue("2.5.4.3", new StringValueTranscoder());
if (value != null) {
names.add(value);
}
}
}
return names.toArray(new String[names.size()]);
}
/**
* Determines if the supplied hostname matches a name derived from the certificate. If the certificate name starts
* with '*', the domain components after the first '.' in each name are compared.
*
* @param hostname to match
* @param certName to match
*
* @return whether the hostname matched the cert name
*/
private boolean isMatch(final String hostname, final String certName)
{
// must start with '*' and contain two domain components
final boolean isWildcard = certName.startsWith("*.") && certName.indexOf('.') < certName.lastIndexOf('.');
logger.trace("matching for hostname={}, certName={}, isWildcard={}", hostname, certName, isWildcard);
boolean match;
if (isWildcard) {
final String certNameDomain = certName.substring(certName.indexOf("."));
final int hostnameIdx = hostname.contains(".") ? hostname.indexOf(".") : hostname.length();
final String hostnameDomain = hostname.substring(hostnameIdx);
match = certNameDomain.equalsIgnoreCase(hostnameDomain);
logger.trace("match={} for {} == {}", match, certNameDomain, hostnameDomain);
} else {
match = certName.equalsIgnoreCase(hostname);
logger.trace("match={} for {} == {}", match, certName, hostname);
}
return match;
}
}