001/** 002 * 003 * Copyright 2015 Florian Schmaus 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017 018package org.jivesoftware.smack.java7; 019 020import java.net.InetAddress; 021import java.net.UnknownHostException; 022import java.security.Principal; 023import java.security.cert.Certificate; 024import java.security.cert.CertificateException; 025import java.security.cert.X509Certificate; 026import java.util.Collection; 027import java.util.LinkedList; 028import java.util.List; 029import java.util.Locale; 030import java.util.logging.Level; 031import java.util.logging.Logger; 032 033import javax.naming.InvalidNameException; 034import javax.naming.ldap.LdapName; 035import javax.naming.ldap.Rdn; 036import javax.net.ssl.HostnameVerifier; 037import javax.net.ssl.SSLPeerUnverifiedException; 038import javax.net.ssl.SSLSession; 039import javax.security.auth.kerberos.KerberosPrincipal; 040 041import org.minidns.util.InetAddressUtil; 042 043/** 044 * HostnameVerifier implementation for XMPP. Verifies a given name, the 'hostname' argument, which 045 * should be the XMPP domain of the used XMPP service. The verifier compares the name with the 046 * servers TLS certificate's <a href="https://tools.ietf.org/html/rfc5280#section-4.2.1.6">Subject 047 * Alternative Name (SAN)</a> DNS name ('dNSName'), and, if there are no SANs, which the Common Name 048 * (CN). 049 * <p> 050 * Based on the <a href="http://kevinlocke.name/bits/2012/10/03/ssl-certificate-verification-in-dispatch-and-asynchttpclient/">work by Kevin 051 * Locke</a> (released under CC0 1.0 Universal / Public Domain Dedication). 052 * </p> 053 */ 054public class XmppHostnameVerifier implements HostnameVerifier { 055 056 private static final Logger LOGGER = Logger.getLogger(XmppHostnameVerifier.class.getName()); 057 058 @Override 059 public boolean verify(String hostname, SSLSession session) { 060 boolean validCertificate = false, validPrincipal = false; 061 try { 062 Certificate[] peerCertificates = session.getPeerCertificates(); 063 if (peerCertificates.length == 0) { 064 return false; 065 } 066 if (!(peerCertificates[0] instanceof X509Certificate)) { 067 return false; 068 } 069 X509Certificate peerCertificate = (X509Certificate) peerCertificates[0]; 070 try { 071 match(hostname, peerCertificate); 072 // Certificate matches hostname 073 validCertificate = true; 074 } 075 catch (CertificateException e) { 076 LOGGER.log(Level.INFO, "Certificate does not match hostname", e); 077 } 078 } 079 catch (SSLPeerUnverifiedException e) { 080 // Not using certificates for peers, try verifying the principal 081 Principal peerPrincipal = null; 082 try { 083 peerPrincipal = session.getPeerPrincipal(); 084 } 085 catch (SSLPeerUnverifiedException e2) { 086 LOGGER.log(Level.INFO, "Can't verify principal for " + hostname + ". Not kerberos", 087 e2); 088 } 089 if (peerPrincipal instanceof KerberosPrincipal) { 090 validPrincipal = match(hostname, (KerberosPrincipal) peerPrincipal); 091 } 092 else { 093 LOGGER.info("Can't verify principal for " + hostname + ". Not kerberos"); 094 } 095 } 096 097 return validCertificate || validPrincipal; 098 } 099 100 private static void match(String name, X509Certificate cert) throws CertificateException { 101 if (InetAddressUtil.isIpAddress(name)) { 102 matchIp(name, cert); 103 } 104 else { 105 matchDns(name, cert); 106 } 107 } 108 109 private static boolean match(String name, KerberosPrincipal peerPrincipal) { 110 // TODO 111 LOGGER.warning("KerberosPrincipal '" + peerPrincipal + "' validation not implemented yet. Can not verify " + name); 112 return false; 113 } 114 115 /** 116 * As defined in RFC 5280 § 4.2.1.6 117 * <pre> 118 * GeneralName ::= CHOICE { 119 * ... 120 * dNSName [2] IA5String, 121 * ... 122 * } 123 * </pre> 124 */ 125 private static final int ALTNAME_DNS = 2; 126 127 /** 128 * Try to match a certificate with a DNS name. This method returns if the certificate matches or 129 * throws a {@link CertificateException} if not. 130 * 131 * @param name the DNS name. 132 * @param cert the certificate. 133 * @throws CertificateException if the DNS name does not match the certificate. 134 */ 135 private static void matchDns(String name, X509Certificate cert) throws CertificateException { 136 Collection<List<?>> subjAltNames = cert.getSubjectAlternativeNames(); 137 if (subjAltNames != null) { 138 List<String> nonMatchingDnsAltnames = new LinkedList<>(); 139 for (List<?> san : subjAltNames) { 140 if (((Integer) san.get(0)).intValue() != ALTNAME_DNS) { 141 continue; 142 } 143 String dnsName = (String) san.get(1); 144 if (matchesPerRfc2818(name, dnsName)) { 145 // Signal success by returning. 146 return; 147 } 148 else { 149 nonMatchingDnsAltnames.add(dnsName); 150 } 151 } 152 if (!nonMatchingDnsAltnames.isEmpty()) { 153 // Reject if certificate contains subject alt names, but none of them matches 154 StringBuilder sb = new StringBuilder("No subject alternative DNS name matching " 155 + name + " found. Tried: "); 156 for (String nonMatchingDnsAltname : nonMatchingDnsAltnames) { 157 sb.append(nonMatchingDnsAltname).append(','); 158 } 159 throw new CertificateException(sb.toString()); 160 } 161 } 162 163 // Control flow will end here if the X509 certificate does not have *any* Subject 164 // Alternative Names (SANs). Fallback trying to validate against the CN of the subject. 165 LdapName dn = null; 166 try { 167 dn = new LdapName(cert.getSubjectX500Principal().getName()); 168 } catch (InvalidNameException e) { 169 LOGGER.warning("Invalid DN: " + e.getMessage()); 170 } 171 if (dn != null) { 172 for (Rdn rdn : dn.getRdns()) { 173 if (rdn.getType().equalsIgnoreCase("CN")) { 174 if (matchesPerRfc2818(name, rdn.getValue().toString())) { 175 // Signal success by returning. 176 return; 177 } 178 break; 179 } 180 } 181 } 182 183 throw new CertificateException("No name matching " + name + " found"); 184 } 185 186 private static boolean matchesPerRfc2818(String name, String template) { 187 String[] nameParts = name.toLowerCase(Locale.US).split("\\."); 188 String[] templateParts = template.toLowerCase(Locale.US).split("\\."); 189 190 if (nameParts.length != templateParts.length) { 191 return false; 192 } 193 194 for (int i = 0; i < nameParts.length; i++) { 195 if (!matchWildCards(nameParts[i], templateParts[i])) { 196 return false; 197 } 198 } 199 200 return true; 201 } 202 203 /** 204 * Returns true if the name matches against the template that may contain the wildcard char '*'. 205 * 206 * @param name TODO javadoc me please 207 * @param template TODO javadoc me please 208 * @return true if <code>name</code> matches <code>template</code>. 209 */ 210 private static boolean matchWildCards(String name, String template) { 211 int wildcardIndex = template.indexOf("*"); 212 if (wildcardIndex == -1) { 213 return name.equals(template); 214 } 215 216 boolean isBeginning = true; 217 String beforeWildcard; 218 String afterWildcard = template; 219 while (wildcardIndex != -1) { 220 beforeWildcard = afterWildcard.substring(0, wildcardIndex); 221 afterWildcard = afterWildcard.substring(wildcardIndex + 1); 222 223 int beforeStartIndex = name.indexOf(beforeWildcard); 224 if ((beforeStartIndex == -1) || (isBeginning && beforeStartIndex != 0)) { 225 return false; 226 } 227 isBeginning = false; 228 229 name = name.substring(beforeStartIndex + beforeWildcard.length()); 230 wildcardIndex = afterWildcard.indexOf("*"); 231 } 232 233 return name.endsWith(afterWildcard); 234 } 235 236 private static final int ALTNAME_IP = 7; 237 238 /** 239 * Check if the certificate allows use of the given IP address. 240 * <p> 241 * From RFC2818 § 3.1: "In some cases, the URI is specified as an IP address rather than a 242 * hostname. In this case, the iPAddress subjectAltName must be present in the certificate and 243 * must exactly match the IP in the URI." 244 * <p> 245 * 246 * @param expectedIP TODO javadoc me please 247 * @param cert TODO javadoc me please 248 * @throws CertificateException 249 */ 250 private static void matchIp(String expectedIP, X509Certificate cert) 251 throws CertificateException { 252 Collection<List<?>> subjectAlternativeNames = cert.getSubjectAlternativeNames(); 253 if (subjectAlternativeNames == null) { 254 throw new CertificateException("No subject alternative names present"); 255 } 256 List<String> nonMatchingIpAltnames = new LinkedList<>(); 257 for (List<?> san : subjectAlternativeNames) { 258 if (((Integer) san.get(0)).intValue() != ALTNAME_IP) { 259 continue; 260 } 261 String ipAddress = (String) san.get(1); 262 if (expectedIP.equalsIgnoreCase(ipAddress)) { 263 return; 264 } 265 else { 266 try { 267 // See if the addresses match if we transform then, useful for IPv6 addresses 268 if (InetAddress.getByName(expectedIP).equals(InetAddress.getByName(ipAddress))) { 269 // expectedIP matches the given ipAddress, return 270 return; 271 } 272 } 273 catch (UnknownHostException | SecurityException e) { 274 LOGGER.log(Level.FINE, "Comparing IP strings failed", e); 275 } 276 } 277 nonMatchingIpAltnames.add(ipAddress); 278 } 279 StringBuilder sb = new StringBuilder("No subject alternative names matching IP address " 280 + expectedIP + " found. Tried: "); 281 for (String s : nonMatchingIpAltnames) { 282 sb.append(s).append(','); 283 } 284 throw new CertificateException(sb.toString()); 285 } 286}