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 in case of a certificate issue.
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}