001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.io.crypto.tls;
019
020import java.security.cert.Certificate;
021import java.security.cert.CertificateParsingException;
022import java.security.cert.X509Certificate;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.Collections;
026import java.util.List;
027import java.util.Locale;
028import java.util.NoSuchElementException;
029import java.util.Objects;
030import javax.naming.InvalidNameException;
031import javax.naming.NamingException;
032import javax.naming.directory.Attribute;
033import javax.naming.directory.Attributes;
034import javax.naming.ldap.LdapName;
035import javax.naming.ldap.Rdn;
036import javax.net.ssl.HostnameVerifier;
037import javax.net.ssl.SSLException;
038import javax.net.ssl.SSLPeerUnverifiedException;
039import javax.net.ssl.SSLSession;
040import javax.security.auth.x500.X500Principal;
041import org.apache.hadoop.hbase.util.Strings;
042import org.apache.yetus.audience.InterfaceAudience;
043import org.slf4j.Logger;
044import org.slf4j.LoggerFactory;
045
046/**
047 * When enabled in {@link X509Util}, handles verifying that the hostname of a peer matches the
048 * certificate it presents.
049 * <p/>
050 * This file has been copied from the Apache ZooKeeper project.
051 * @see <a href=
052 *      "https://github.com/apache/zookeeper/blob/5820d10d9dc58c8e12d2e25386fdf92acb360359/zookeeper-server/src/main/java/org/apache/zookeeper/common/ZKHostnameVerifier.java">Base
053 *      revision</a>
054 */
055@InterfaceAudience.Private
056final class HBaseHostnameVerifier implements HostnameVerifier {
057
058  private static final Logger LOG = LoggerFactory.getLogger(HBaseHostnameVerifier.class);
059
060  /**
061   * Note: copied from Apache httpclient with some minor modifications. We want host verification,
062   * but depending on the httpclient jar caused unexplained performance regressions (even when the
063   * code was not used).
064   */
065  private static final class SubjectName {
066
067    static final int DNS = 2;
068    static final int IP = 7;
069
070    private final String value;
071    private final int type;
072
073    SubjectName(final String value, final int type) {
074      if (type != DNS && type != IP) {
075        throw new IllegalArgumentException("Invalid type: " + type);
076      }
077      this.value = Objects.requireNonNull(value);
078      this.type = type;
079    }
080
081    public int getType() {
082      return type;
083    }
084
085    public String getValue() {
086      return value;
087    }
088
089    @Override
090    public String toString() {
091      return value;
092    }
093
094  }
095
096  @Override
097  public boolean verify(final String host, final SSLSession session) {
098    try {
099      final Certificate[] certs = session.getPeerCertificates();
100      final X509Certificate x509 = (X509Certificate) certs[0];
101      verify(host, x509);
102      return true;
103    } catch (final SSLException ex) {
104      LOG.debug("Unexpected exception", ex);
105      return false;
106    }
107  }
108
109  void verify(final String host, final X509Certificate cert) throws SSLException {
110    final List<SubjectName> subjectAlts = getSubjectAltNames(cert);
111    if (subjectAlts != null && !subjectAlts.isEmpty()) {
112      if (Strings.isInetAddress(host)) {
113        matchIPAddress(host, subjectAlts);
114      } else {
115        matchDNSName(host, subjectAlts);
116      }
117    } else {
118      // CN matching has been deprecated by rfc2818 and can be used
119      // as fallback only when no subjectAlts are available
120      final X500Principal subjectPrincipal = cert.getSubjectX500Principal();
121      final String cn = extractCN(subjectPrincipal.getName(X500Principal.RFC2253));
122      if (cn == null) {
123        throw new SSLException("Certificate subject for <" + host + "> doesn't contain "
124          + "a common name and does not have alternative names");
125      }
126      matchCN(host, cn);
127    }
128  }
129
130  private static void matchIPAddress(final String host, final List<SubjectName> subjectAlts)
131    throws SSLException {
132    for (final SubjectName subjectAlt : subjectAlts) {
133      if (
134        subjectAlt.getType() == SubjectName.IP
135          && Strings.hostnamesEqual(host, subjectAlt.getValue())
136      ) {
137        return;
138      }
139    }
140    throw new SSLPeerUnverifiedException("Certificate for <" + host + "> doesn't match any "
141      + "of the subject alternative names: " + subjectAlts);
142  }
143
144  private static void matchDNSName(final String host, final List<SubjectName> subjectAlts)
145    throws SSLException {
146    final String normalizedHost = host.toLowerCase(Locale.ROOT);
147    for (final SubjectName subjectAlt : subjectAlts) {
148      if (subjectAlt.getType() == SubjectName.DNS) {
149        final String normalizedSubjectAlt = subjectAlt.getValue().toLowerCase(Locale.ROOT);
150        if (matchIdentityStrict(normalizedHost, normalizedSubjectAlt)) {
151          return;
152        }
153      }
154    }
155    throw new SSLPeerUnverifiedException("Certificate for <" + host + "> doesn't match any "
156      + "of the subject alternative names: " + subjectAlts);
157  }
158
159  private static void matchCN(final String host, final String cn) throws SSLException {
160    final String normalizedHost = host.toLowerCase(Locale.ROOT);
161    final String normalizedCn = cn.toLowerCase(Locale.ROOT);
162    if (!matchIdentityStrict(normalizedHost, normalizedCn)) {
163      throw new SSLPeerUnverifiedException("Certificate for <" + host + "> doesn't match "
164        + "common name of the certificate subject: " + cn);
165    }
166  }
167
168  private static boolean matchIdentity(final String host, final String identity,
169    final boolean strict) {
170    // RFC 2818, 3.1. Server Identity
171    // "...Names may contain the wildcard
172    // character * which is considered to match any single domain name
173    // component or component fragment..."
174    // Based on this statement presuming only singular wildcard is legal
175    final int asteriskIdx = identity.indexOf('*');
176    if (asteriskIdx != -1) {
177      final String prefix = identity.substring(0, asteriskIdx);
178      final String suffix = identity.substring(asteriskIdx + 1);
179      if (!prefix.isEmpty() && !host.startsWith(prefix)) {
180        return false;
181      }
182      if (!suffix.isEmpty() && !host.endsWith(suffix)) {
183        return false;
184      }
185      // Additional sanity checks on content selected by wildcard can be done here
186      if (strict) {
187        final String remainder = host.substring(prefix.length(), host.length() - suffix.length());
188        return !remainder.contains(".");
189      }
190      return true;
191    }
192    return host.equalsIgnoreCase(identity);
193  }
194
195  private static boolean matchIdentityStrict(final String host, final String identity) {
196    return matchIdentity(host, identity, true);
197  }
198
199  private static String extractCN(final String subjectPrincipal) throws SSLException {
200    if (subjectPrincipal == null) {
201      return null;
202    }
203    try {
204      final LdapName subjectDN = new LdapName(subjectPrincipal);
205      final List<Rdn> rdns = subjectDN.getRdns();
206      for (int i = rdns.size() - 1; i >= 0; i--) {
207        final Rdn rds = rdns.get(i);
208        final Attributes attributes = rds.toAttributes();
209        final Attribute cn = attributes.get("cn");
210        if (cn != null) {
211          try {
212            final Object value = cn.get();
213            if (value != null) {
214              return value.toString();
215            }
216          } catch (final NoSuchElementException ignore) {
217            // ignore exception
218          } catch (final NamingException ignore) {
219            // ignore exception
220          }
221        }
222      }
223      return null;
224    } catch (final InvalidNameException e) {
225      throw new SSLException(subjectPrincipal + " is not a valid X500 distinguished name");
226    }
227  }
228
229  @SuppressWarnings("MixedMutabilityReturnType")
230  private static List<SubjectName> getSubjectAltNames(final X509Certificate cert) {
231    try {
232      final Collection<List<?>> entries = cert.getSubjectAlternativeNames();
233      if (entries == null) {
234        return Collections.emptyList();
235      }
236      final List<SubjectName> result = new ArrayList<SubjectName>();
237      for (List<?> entry : entries) {
238        final Integer type = entry.size() >= 2 ? (Integer) entry.get(0) : null;
239        if (type != null) {
240          if (type == SubjectName.DNS || type == SubjectName.IP) {
241            final Object o = entry.get(1);
242            if (o instanceof String) {
243              result.add(new SubjectName((String) o, type));
244            } else {
245              LOG.debug("non-string Subject Alt Name type detected, not currently supported: {}",
246                o);
247            }
248          }
249        }
250      }
251      return result;
252    } catch (final CertificateParsingException ignore) {
253      return Collections.emptyList();
254    }
255  }
256}