Smack/smack-java8/src/main/java/org/jivesoftware/smack/java7/XmppHostnameVerifier.java

287 lines
11 KiB
Java

/**
*
* Copyright 2015 Florian Schmaus
*
* 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.jivesoftware.smack.java7;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.Principal;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.security.auth.kerberos.KerberosPrincipal;
import org.minidns.util.InetAddressUtil;
/**
* HostnameVerifier implementation for XMPP. Verifies a given name, the 'hostname' argument, which
* should be the XMPP domain of the used XMPP service. The verifier compares the name with the
* servers TLS certificate's <a href="https://tools.ietf.org/html/rfc5280#section-4.2.1.6">Subject
* Alternative Name (SAN)</a> DNS name ('dNSName'), and, if there are no SANs, which the Common Name
* (CN).
* <p>
* Based on the <a href="http://kevinlocke.name/bits/2012/10/03/ssl-certificate-verification-in-dispatch-and-asynchttpclient/">work by Kevin
* Locke</a> (released under CC0 1.0 Universal / Public Domain Dedication).
* </p>
*/
public class XmppHostnameVerifier implements HostnameVerifier {
private static final Logger LOGGER = Logger.getLogger(XmppHostnameVerifier.class.getName());
@Override
public boolean verify(String hostname, SSLSession session) {
boolean validCertificate = false, validPrincipal = false;
try {
Certificate[] peerCertificates = session.getPeerCertificates();
if (peerCertificates.length == 0) {
return false;
}
if (!(peerCertificates[0] instanceof X509Certificate)) {
return false;
}
X509Certificate peerCertificate = (X509Certificate) peerCertificates[0];
try {
match(hostname, peerCertificate);
// Certificate matches hostname
validCertificate = true;
}
catch (CertificateException e) {
LOGGER.log(Level.INFO, "Certificate does not match hostname", e);
}
}
catch (SSLPeerUnverifiedException e) {
// Not using certificates for peers, try verifying the principal
Principal peerPrincipal = null;
try {
peerPrincipal = session.getPeerPrincipal();
}
catch (SSLPeerUnverifiedException e2) {
LOGGER.log(Level.INFO, "Can't verify principal for " + hostname + ". Not kerberos",
e2);
}
if (peerPrincipal instanceof KerberosPrincipal) {
validPrincipal = match(hostname, (KerberosPrincipal) peerPrincipal);
}
else {
LOGGER.info("Can't verify principal for " + hostname + ". Not kerberos");
}
}
return validCertificate || validPrincipal;
}
private static void match(String name, X509Certificate cert) throws CertificateException {
if (InetAddressUtil.isIpAddress(name)) {
matchIp(name, cert);
}
else {
matchDns(name, cert);
}
}
private static boolean match(String name, KerberosPrincipal peerPrincipal) {
// TODO
LOGGER.warning("KerberosPrincipal '" + peerPrincipal + "' validation not implemented yet. Can not verify " + name);
return false;
}
/**
* As defined in RFC 5280 § 4.2.1.6
* <pre>
* GeneralName ::= CHOICE {
* ...
* dNSName [2] IA5String,
* ...
* }
* </pre>
*/
private static final int ALTNAME_DNS = 2;
/**
* Try to match a certificate with a DNS name. This method returns if the certificate matches or
* throws a {@link CertificateException} if not.
*
* @param name the DNS name.
* @param cert the certificate.
* @throws CertificateException if the DNS name does not match the certificate.
*/
private static void matchDns(String name, X509Certificate cert) throws CertificateException {
Collection<List<?>> subjAltNames = cert.getSubjectAlternativeNames();
if (subjAltNames != null) {
List<String> nonMatchingDnsAltnames = new LinkedList<>();
for (List<?> san : subjAltNames) {
if (((Integer) san.get(0)).intValue() != ALTNAME_DNS) {
continue;
}
String dnsName = (String) san.get(1);
if (matchesPerRfc2818(name, dnsName)) {
// Signal success by returning.
return;
}
else {
nonMatchingDnsAltnames.add(dnsName);
}
}
if (!nonMatchingDnsAltnames.isEmpty()) {
// Reject if certificate contains subject alt names, but none of them matches
StringBuilder sb = new StringBuilder("No subject alternative DNS name matching "
+ name + " found. Tried: ");
for (String nonMatchingDnsAltname : nonMatchingDnsAltnames) {
sb.append(nonMatchingDnsAltname).append(',');
}
throw new CertificateException(sb.toString());
}
}
// Control flow will end here if the X509 certificate does not have *any* Subject
// Alternative Names (SANs). Fallback trying to validate against the CN of the subject.
LdapName dn = null;
try {
dn = new LdapName(cert.getSubjectX500Principal().getName());
} catch (InvalidNameException e) {
LOGGER.warning("Invalid DN: " + e.getMessage());
}
if (dn != null) {
for (Rdn rdn : dn.getRdns()) {
if (rdn.getType().equalsIgnoreCase("CN")) {
if (matchesPerRfc2818(name, rdn.getValue().toString())) {
// Signal success by returning.
return;
}
break;
}
}
}
throw new CertificateException("No name matching " + name + " found");
}
private static boolean matchesPerRfc2818(String name, String template) {
String[] nameParts = name.toLowerCase(Locale.US).split("\\.");
String[] templateParts = template.toLowerCase(Locale.US).split("\\.");
if (nameParts.length != templateParts.length) {
return false;
}
for (int i = 0; i < nameParts.length; i++) {
if (!matchWildCards(nameParts[i], templateParts[i])) {
return false;
}
}
return true;
}
/**
* Returns true if the name matches against the template that may contain the wildcard char '*'.
*
* @param name TODO javadoc me please
* @param template TODO javadoc me please
* @return true if <code>name</code> matches <code>template</code>.
*/
private static boolean matchWildCards(String name, String template) {
int wildcardIndex = template.indexOf("*");
if (wildcardIndex == -1) {
return name.equals(template);
}
boolean isBeginning = true;
String beforeWildcard;
String afterWildcard = template;
while (wildcardIndex != -1) {
beforeWildcard = afterWildcard.substring(0, wildcardIndex);
afterWildcard = afterWildcard.substring(wildcardIndex + 1);
int beforeStartIndex = name.indexOf(beforeWildcard);
if ((beforeStartIndex == -1) || (isBeginning && beforeStartIndex != 0)) {
return false;
}
isBeginning = false;
name = name.substring(beforeStartIndex + beforeWildcard.length());
wildcardIndex = afterWildcard.indexOf("*");
}
return name.endsWith(afterWildcard);
}
private static final int ALTNAME_IP = 7;
/**
* Check if the certificate allows use of the given IP address.
* <p>
* From RFC2818 § 3.1: "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 expectedIP TODO javadoc me please
* @param cert TODO javadoc me please
* @throws CertificateException
*/
private static void matchIp(String expectedIP, X509Certificate cert)
throws CertificateException {
Collection<List<?>> subjectAlternativeNames = cert.getSubjectAlternativeNames();
if (subjectAlternativeNames == null) {
throw new CertificateException("No subject alternative names present");
}
List<String> nonMatchingIpAltnames = new LinkedList<>();
for (List<?> san : subjectAlternativeNames) {
if (((Integer) san.get(0)).intValue() != ALTNAME_IP) {
continue;
}
String ipAddress = (String) san.get(1);
if (expectedIP.equalsIgnoreCase(ipAddress)) {
return;
}
else {
try {
// See if the addresses match if we transform then, useful for IPv6 addresses
if (InetAddress.getByName(expectedIP).equals(InetAddress.getByName(ipAddress))) {
// expectedIP matches the given ipAddress, return
return;
}
}
catch (UnknownHostException | SecurityException e) {
LOGGER.log(Level.FINE, "Comparing IP strings failed", e);
}
}
nonMatchingIpAltnames.add(ipAddress);
}
StringBuilder sb = new StringBuilder("No subject alternative names matching IP address "
+ expectedIP + " found. Tried: ");
for (String s : nonMatchingIpAltnames) {
sb.append(s).append(',');
}
throw new CertificateException(sb.toString());
}
}