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.net.InetAddress;
021import java.security.cert.Certificate;
022import java.security.cert.CertificateParsingException;
023import java.security.cert.X509Certificate;
024import java.util.ArrayList;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.List;
028import java.util.Locale;
029import java.util.NoSuchElementException;
030import java.util.Objects;
031import java.util.Optional;
032import javax.naming.InvalidNameException;
033import javax.naming.NamingException;
034import javax.naming.directory.Attribute;
035import javax.naming.directory.Attributes;
036import javax.naming.ldap.LdapName;
037import javax.naming.ldap.Rdn;
038import javax.net.ssl.HostnameVerifier;
039import javax.net.ssl.SSLException;
040import javax.net.ssl.SSLPeerUnverifiedException;
041import javax.net.ssl.SSLSession;
042import javax.security.auth.x500.X500Principal;
043import org.apache.yetus.audience.InterfaceAudience;
044import org.slf4j.Logger;
045import org.slf4j.LoggerFactory;
046
047import org.apache.hbase.thirdparty.com.google.common.net.InetAddresses;
048
049/**
050 * When enabled in {@link X509Util}, handles verifying that the hostname of a peer matches the
051 * certificate it presents.
052 * <p/>
053 * This file has been copied from the Apache ZooKeeper project.
054 * @see <a href=
055 *      "https://github.com/apache/zookeeper/blob/5820d10d9dc58c8e12d2e25386fdf92acb360359/zookeeper-server/src/main/java/org/apache/zookeeper/common/ZKHostnameVerifier.java">Base
056 *      revision</a>
057 */
058@InterfaceAudience.Private
059final class HBaseHostnameVerifier implements HostnameVerifier {
060
061  private static final Logger LOG = LoggerFactory.getLogger(HBaseHostnameVerifier.class);
062
063  /**
064   * Note: copied from Apache httpclient with some minor modifications. We want host verification,
065   * but depending on the httpclient jar caused unexplained performance regressions (even when the
066   * code was not used).
067   */
068  private static final class SubjectName {
069
070    static final int DNS = 2;
071    static final int IP = 7;
072
073    private final String value;
074    private final int type;
075
076    SubjectName(final String value, final int type) {
077      if (type != DNS && type != IP) {
078        throw new IllegalArgumentException("Invalid type: " + type);
079      }
080      this.value = Objects.requireNonNull(value);
081      this.type = type;
082    }
083
084    public int getType() {
085      return type;
086    }
087
088    public String getValue() {
089      return value;
090    }
091
092    @Override
093    public String toString() {
094      return value;
095    }
096
097  }
098
099  @Override
100  public boolean verify(final String host, final SSLSession session) {
101    try {
102      final Certificate[] certs = session.getPeerCertificates();
103      final X509Certificate x509 = (X509Certificate) certs[0];
104      verify(host, x509);
105      return true;
106    } catch (final SSLException ex) {
107      LOG.debug("Unexpected exception", ex);
108      return false;
109    }
110  }
111
112  void verify(final String host, final X509Certificate cert) throws SSLException {
113    final List<SubjectName> subjectAlts = getSubjectAltNames(cert);
114    if (subjectAlts != null && !subjectAlts.isEmpty()) {
115      Optional<InetAddress> inetAddress = parseIpAddress(host);
116      if (inetAddress.isPresent()) {
117        matchIPAddress(host, inetAddress.get(), subjectAlts);
118      } else {
119        matchDNSName(host, subjectAlts);
120      }
121    } else {
122      // CN matching has been deprecated by rfc2818 and can be used
123      // as fallback only when no subjectAlts are available
124      final X500Principal subjectPrincipal = cert.getSubjectX500Principal();
125      final String cn = extractCN(subjectPrincipal.getName(X500Principal.RFC2253));
126      if (cn == null) {
127        throw new SSLException("Certificate subject for <" + host + "> doesn't contain "
128          + "a common name and does not have alternative names");
129      }
130      matchCN(host, cn);
131    }
132  }
133
134  private static void matchIPAddress(final String host, final InetAddress inetAddress,
135    final List<SubjectName> subjectAlts) throws SSLException {
136    for (final SubjectName subjectAlt : subjectAlts) {
137      if (subjectAlt.getType() == SubjectName.IP) {
138        Optional<InetAddress> parsed = parseIpAddress(subjectAlt.getValue());
139        if (parsed.filter(altAddr -> altAddr.equals(inetAddress)).isPresent()) {
140          return;
141        }
142      }
143    }
144    throw new SSLPeerUnverifiedException("Certificate for <" + host + "> doesn't match any "
145      + "of the subject alternative names: " + subjectAlts);
146  }
147
148  private static void matchDNSName(final String host, final List<SubjectName> subjectAlts)
149    throws SSLException {
150    final String normalizedHost = host.toLowerCase(Locale.ROOT);
151    for (final SubjectName subjectAlt : subjectAlts) {
152      if (subjectAlt.getType() == SubjectName.DNS) {
153        final String normalizedSubjectAlt = subjectAlt.getValue().toLowerCase(Locale.ROOT);
154        if (matchIdentityStrict(normalizedHost, normalizedSubjectAlt)) {
155          return;
156        }
157      }
158    }
159    throw new SSLPeerUnverifiedException("Certificate for <" + host + "> doesn't match any "
160      + "of the subject alternative names: " + subjectAlts);
161  }
162
163  private static void matchCN(final String host, final String cn) throws SSLException {
164    final String normalizedHost = host.toLowerCase(Locale.ROOT);
165    final String normalizedCn = cn.toLowerCase(Locale.ROOT);
166    if (!matchIdentityStrict(normalizedHost, normalizedCn)) {
167      throw new SSLPeerUnverifiedException("Certificate for <" + host + "> doesn't match "
168        + "common name of the certificate subject: " + cn);
169    }
170  }
171
172  private static boolean matchIdentity(final String host, final String identity,
173    final boolean strict) {
174    // RFC 2818, 3.1. Server Identity
175    // "...Names may contain the wildcard
176    // character * which is considered to match any single domain name
177    // component or component fragment..."
178    // Based on this statement presuming only singular wildcard is legal
179    final int asteriskIdx = identity.indexOf('*');
180    if (asteriskIdx != -1) {
181      final String prefix = identity.substring(0, asteriskIdx);
182      final String suffix = identity.substring(asteriskIdx + 1);
183      if (!prefix.isEmpty() && !host.startsWith(prefix)) {
184        return false;
185      }
186      if (!suffix.isEmpty() && !host.endsWith(suffix)) {
187        return false;
188      }
189      // Additional sanity checks on content selected by wildcard can be done here
190      if (strict) {
191        final String remainder = host.substring(prefix.length(), host.length() - suffix.length());
192        return !remainder.contains(".");
193      }
194      return true;
195    }
196    return host.equalsIgnoreCase(identity);
197  }
198
199  private static boolean matchIdentityStrict(final String host, final String identity) {
200    return matchIdentity(host, identity, true);
201  }
202
203  private static String extractCN(final String subjectPrincipal) throws SSLException {
204    if (subjectPrincipal == null) {
205      return null;
206    }
207    try {
208      final LdapName subjectDN = new LdapName(subjectPrincipal);
209      final List<Rdn> rdns = subjectDN.getRdns();
210      for (int i = rdns.size() - 1; i >= 0; i--) {
211        final Rdn rds = rdns.get(i);
212        final Attributes attributes = rds.toAttributes();
213        final Attribute cn = attributes.get("cn");
214        if (cn != null) {
215          try {
216            final Object value = cn.get();
217            if (value != null) {
218              return value.toString();
219            }
220          } catch (final NoSuchElementException ignore) {
221            // ignore exception
222          } catch (final NamingException ignore) {
223            // ignore exception
224          }
225        }
226      }
227      return null;
228    } catch (final InvalidNameException e) {
229      throw new SSLException(subjectPrincipal + " is not a valid X500 distinguished name");
230    }
231  }
232
233  private static Optional<InetAddress> parseIpAddress(String host) {
234    host = host.trim();
235    // Uri strings only work for ipv6 and are wrapped with brackets
236    // Unfortunately InetAddresses can't handle a mixed input, so we
237    // check here and choose which parse method to use.
238    if (host.startsWith("[") && host.endsWith("]")) {
239      return parseIpAddressUriString(host);
240    } else {
241      return parseIpAddressString(host);
242    }
243  }
244
245  private static Optional<InetAddress> parseIpAddressUriString(String host) {
246    if (InetAddresses.isUriInetAddress(host)) {
247      return Optional.of(InetAddresses.forUriString(host));
248    }
249    return Optional.empty();
250  }
251
252  private static Optional<InetAddress> parseIpAddressString(String host) {
253    if (InetAddresses.isInetAddress(host)) {
254      return Optional.of(InetAddresses.forString(host));
255    }
256    return Optional.empty();
257  }
258
259  @SuppressWarnings("MixedMutabilityReturnType")
260  private static List<SubjectName> getSubjectAltNames(final X509Certificate cert) {
261    try {
262      final Collection<List<?>> entries = cert.getSubjectAlternativeNames();
263      if (entries == null) {
264        return Collections.emptyList();
265      }
266      final List<SubjectName> result = new ArrayList<SubjectName>();
267      for (List<?> entry : entries) {
268        final Integer type = entry.size() >= 2 ? (Integer) entry.get(0) : null;
269        if (type != null) {
270          if (type == SubjectName.DNS || type == SubjectName.IP) {
271            final Object o = entry.get(1);
272            if (o instanceof String) {
273              result.add(new SubjectName((String) o, type));
274            } else {
275              LOG.debug("non-string Subject Alt Name type detected, not currently supported: {}",
276                o);
277            }
278          }
279        }
280      }
281      return result;
282    } catch (final CertificateParsingException ignore) {
283      return Collections.emptyList();
284    }
285  }
286}