001/*
002 * Copyright 2014-2020 Ping Identity Corporation
003 * All Rights Reserved.
004 */
005/*
006 * Copyright 2014-2020 Ping Identity Corporation
007 *
008 * Licensed under the Apache License, Version 2.0 (the "License");
009 * you may not use this file except in compliance with the License.
010 * You may obtain a copy of the License at
011 *
012 *    http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing, software
015 * distributed under the License is distributed on an "AS IS" BASIS,
016 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
017 * See the License for the specific language governing permissions and
018 * limitations under the License.
019 */
020/*
021 * Copyright (C) 2014-2020 Ping Identity Corporation
022 *
023 * This program is free software; you can redistribute it and/or modify
024 * it under the terms of the GNU General Public License (GPLv2 only)
025 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
026 * as published by the Free Software Foundation.
027 *
028 * This program is distributed in the hope that it will be useful,
029 * but WITHOUT ANY WARRANTY; without even the implied warranty of
030 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
031 * GNU General Public License for more details.
032 *
033 * You should have received a copy of the GNU General Public License
034 * along with this program; if not, see <http://www.gnu.org/licenses>.
035 */
036package com.unboundid.util.ssl;
037
038
039
040import java.net.InetAddress;
041import java.net.URI;
042import java.util.Collection;
043import java.util.List;
044import java.security.cert.Certificate;
045import java.security.cert.X509Certificate;
046import javax.net.ssl.SSLSession;
047import javax.net.ssl.SSLSocket;
048import javax.security.auth.x500.X500Principal;
049
050import com.unboundid.asn1.ASN1OctetString;
051import com.unboundid.ldap.matchingrules.CaseIgnoreStringMatchingRule;
052import com.unboundid.ldap.sdk.DN;
053import com.unboundid.ldap.sdk.Filter;
054import com.unboundid.ldap.sdk.LDAPConnectionOptions;
055import com.unboundid.ldap.sdk.LDAPException;
056import com.unboundid.ldap.sdk.RDN;
057import com.unboundid.ldap.sdk.ResultCode;
058import com.unboundid.util.Debug;
059import com.unboundid.util.NotMutable;
060import com.unboundid.util.NotNull;
061import com.unboundid.util.Nullable;
062import com.unboundid.util.ObjectPair;
063import com.unboundid.util.StaticUtils;
064import com.unboundid.util.ThreadSafety;
065import com.unboundid.util.ThreadSafetyLevel;
066import com.unboundid.util.args.IPAddressArgumentValueValidator;
067
068import static com.unboundid.util.ssl.SSLMessages.*;
069
070
071
072/**
073 * This class provides an implementation of an {@code SSLSocket} verifier that
074 * will verify that the presented server certificate includes the address to
075 * which the client intended to establish a connection.  It will check the CN
076 * attribute of the certificate subject, as well as certain subjectAltName
077 * extensions, including dNSName, uniformResourceIdentifier, and iPAddress.
078 */
079@NotMutable()
080@ThreadSafety(level=ThreadSafetyLevel.COMPLETELY_THREADSAFE)
081public final class HostNameSSLSocketVerifier
082       extends SSLSocketVerifier
083{
084  /**
085   * The name of a system property that can be used to specify the default
086   * behavior that the verifier should exhibit when checking certificates that
087   * contain both a CN attribute in the subject DN and a subject alternative
088   * name extension that contains one or more dNSName,
089   * uniformResourceIdentifier, or iPAddress values. Although RFC 6125 section
090   * 6.4.4 indicates that the CN attribute should not be checked in certificates
091   * that have an appropriate subject alternative name extension, LDAP clients
092   * historically treat both sources as equally valid.
093   */
094  @NotNull public static final String
095       PROPERTY_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT =
096            HostNameSSLSocketVerifier.class.getName() +
097                 ".checkCNWhenSubjectAltNameIsPresent";
098
099
100
101  /**
102   * Indicates whether to check the CN attribute in the peer certificate's
103   * subject DN when that certificate also contains a subject subject
104   * alternative name extension.
105   */
106  static final boolean DEFAULT_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT;
107  static
108  {
109    boolean checkCN = true;
110    final String propValue = StaticUtils.getSystemProperty(
111         PROPERTY_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT);
112    if ((propValue != null) && propValue.equalsIgnoreCase("false"))
113    {
114      checkCN = false;
115    }
116
117    DEFAULT_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT = checkCN;
118  }
119
120
121
122  // Indicates whether to allow wildcard certificates which contain an asterisk
123  // as the first component of a CN subject attribute or dNSName subjectAltName
124  // extension.
125  private final boolean allowWildcards;
126
127  // Indicates whether to check the CN attribute in the peer certificate's
128  // subject DN if the certificate also contains a subject alternative name
129  // extension that contains at least dNSName, uniformResourceIdentifier, or
130  // iPAddress value.
131  private final boolean checkCNWhenSubjectAltNameIsPresent;
132
133
134
135  /**
136   * Creates a new instance of this {@code SSLSocket} verifier.
137   *
138   * @param  allowWildcards  Indicates whether to allow wildcard certificates
139   *                         that contain an asterisk in the leftmost component
140   *                         of a hostname in the dNSName or
141   *                         uniformResourceIdentifier of the subject
142   *                         alternative name extension, or in the CN attribute
143   *                         of the subject DN.
144   */
145  public HostNameSSLSocketVerifier(final boolean allowWildcards)
146  {
147    this(allowWildcards, DEFAULT_CHECK_CN_WHEN_SUBJECT_ALT_NAME_IS_PRESENT);
148  }
149
150
151
152  /**
153   * Creates a new instance of this {@code SSLSocket} verifier.
154   *
155   * @param  allowWildcards
156   *              Indicates whether to allow wildcard certificates that contain
157   *              an asterisk in the leftmost component of a hostname in the
158   *              dNSName or uniformResourceIdentifier of the subject
159   *              alternative name extension, or in the CN attribute of the
160   *              subject DN.
161   * @param  checkCNWhenSubjectAltNameIsPresent
162   *              Indicates whether to check the CN attribute in the peer
163   *              certificate's subject DN if the certificate also contains a
164   *              subject alternative name extension that contains at least one
165   *              dNSName, uniformResourceIdentifier, or iPAddress value.
166   *              Although RFC 6125 section 6.4.4 indicates that the CN
167   *              attribute should not be checked in certificates that have an
168   *              appropriate subject alternative name extension, LDAP clients
169   *              historically treat both sources as equally valid.
170   */
171  public HostNameSSLSocketVerifier(final boolean allowWildcards,
172              final boolean checkCNWhenSubjectAltNameIsPresent)
173  {
174    this.allowWildcards = allowWildcards;
175    this.checkCNWhenSubjectAltNameIsPresent =
176         checkCNWhenSubjectAltNameIsPresent;
177  }
178
179
180
181  /**
182   * Verifies that the provided {@code SSLSocket} is acceptable and the
183   * connection should be allowed to remain established.
184   *
185   * @param  host       The address to which the client intended the connection
186   *                    to be established.
187   * @param  port       The port to which the client intended the connection to
188   *                    be established.
189   * @param  sslSocket  The {@code SSLSocket} that should be verified.
190   *
191   * @throws  LDAPException  If a problem is identified that should prevent the
192   *                         provided {@code SSLSocket} from remaining
193   *                         established.
194   */
195  @Override()
196  public void verifySSLSocket(@NotNull final String host, final int port,
197                              @NotNull final SSLSocket sslSocket)
198         throws LDAPException
199  {
200    try
201    {
202      // Get the certificates presented during negotiation.  The certificates
203      // will be ordered so that the server certificate comes first.
204      final SSLSession sslSession = sslSocket.getSession();
205      if (sslSession == null)
206      {
207        throw new LDAPException(ResultCode.CONNECT_ERROR,
208             ERR_HOST_NAME_SSL_SOCKET_VERIFIER_NO_SESSION.get(host, port));
209      }
210
211      final Certificate[] peerCertificateChain =
212           sslSession.getPeerCertificates();
213      if ((peerCertificateChain == null) || (peerCertificateChain.length == 0))
214      {
215        throw new LDAPException(ResultCode.CONNECT_ERROR,
216             ERR_HOST_NAME_SSL_SOCKET_VERIFIER_NO_PEER_CERTS.get(host, port));
217      }
218
219      if (peerCertificateChain[0] instanceof X509Certificate)
220      {
221        final StringBuilder certInfo = new StringBuilder();
222        if (! certificateIncludesHostname(host,
223             (X509Certificate) peerCertificateChain[0], allowWildcards,
224             checkCNWhenSubjectAltNameIsPresent, certInfo))
225        {
226          throw new LDAPException(ResultCode.CONNECT_ERROR,
227               ERR_HOST_NAME_SSL_SOCKET_VERIFIER_HOSTNAME_NOT_FOUND.get(host,
228                    certInfo.toString()));
229        }
230      }
231      else
232      {
233        throw new LDAPException(ResultCode.CONNECT_ERROR,
234             ERR_HOST_NAME_SSL_SOCKET_VERIFIER_PEER_NOT_X509.get(host, port,
235                  peerCertificateChain[0].getType()));
236      }
237    }
238    catch (final LDAPException le)
239    {
240      Debug.debugException(le);
241      throw le;
242    }
243    catch (final Exception e)
244    {
245      Debug.debugException(e);
246      throw new LDAPException(ResultCode.CONNECT_ERROR,
247           ERR_HOST_NAME_SSL_SOCKET_VERIFIER_EXCEPTION.get(host, port,
248                StaticUtils.getExceptionMessage(e)),
249           e);
250    }
251  }
252
253
254
255  /**
256   * Determines whether the provided certificate contains the specified
257   * hostname.
258   *
259   * @param  host
260   *              The address expected to be found in the provided certificate.
261   * @param  certificate
262   *              The peer certificate to be validated.
263   * @param  allowWildcards
264   *              Indicates whether to allow wildcard certificates that contain
265   *              an asterisk in the leftmost component of a hostname in the
266   *              dNSName or uniformResourceIdentifier of the subject
267   *              alternative name extension, or in the CN attribute of the
268   *              subject DN.
269   * @param  checkCNWhenSubjectAltNameIsPresent
270   *              Indicates whether to check the CN attribute in the peer
271   *              certificate's subject DN if the certificate also contains a
272   *              subject alternative name extension that contains at least one
273   *              dNSName, uniformResourceIdentifier, or iPAddress value.  RFC
274   *              6125 section 6.4.4 indicates that the CN attribute should not
275   *              be checked in certificates that have an appropriate subject
276   *              alternative name extension, although some clients may expect
277   *              CN matching anyway.
278   * @param  certInfo
279   *              A buffer into which information will be provided about the
280   *              provided certificate.
281   *
282   * @return  {@code true} if the expected hostname was found in the
283   *          certificate, or {@code false} if not.
284   */
285  static boolean certificateIncludesHostname(@NotNull final String host,
286                      @NotNull final X509Certificate certificate,
287                      final boolean allowWildcards,
288                      final boolean checkCNWhenSubjectAltNameIsPresent,
289                      @NotNull final StringBuilder certInfo)
290  {
291    // Check to see if the provided hostname is an IP address.
292    InetAddress hostInetAddress = null;
293    if (IPAddressArgumentValueValidator.isValidNumericIPAddress(host))
294    {
295      try
296      {
297        hostInetAddress =
298             LDAPConnectionOptions.DEFAULT_NAME_RESOLVER.getByName(host);
299      }
300      catch (final Exception e)
301      {
302        Debug.debugException(e);
303      }
304    }
305
306
307    // Check to see if the certificate has a subject alternative name extension.
308    // If so, then check its dNSName, uniformResourceLocator, and iPAddress
309    // elements.
310    boolean hasAuthoritativeSubjectAlternativeName = false;
311    try
312    {
313      final Collection<List<?>> subjectAltNames;
314      subjectAltNames = certificate.getSubjectAlternativeNames();
315      if (subjectAltNames != null)
316      {
317        for (final List<?> l : subjectAltNames)
318        {
319          final Integer type = (Integer) l.get(0);
320          switch (type)
321          {
322            case 2: // dNSName
323              final String dnsName = (String) l.get(1);
324              certInfo.append(" dNSName='");
325              certInfo.append(dnsName);
326              certInfo.append('\'');
327
328              if (hostnameMatches(host, dnsName, allowWildcards))
329              {
330                return true;
331              }
332
333              hasAuthoritativeSubjectAlternativeName = true;
334              break;
335
336            case 6: // uniformResourceIdentifier
337              final String uriString = (String) l.get(1);
338              certInfo.append(" uniformResourceIdentifier='");
339              certInfo.append(uriString);
340              certInfo.append('\'');
341
342              final String uriHost = getHostFromURI(uriString);
343              if (uriHost != null)
344              {
345                if (IPAddressArgumentValueValidator.isValidNumericIPAddress(
346                     uriHost))
347                {
348                  if ((hostInetAddress != null) &&
349                       ipAddressMatches(hostInetAddress, uriHost))
350                  {
351                    return true;
352                  }
353                }
354                else if (hostnameMatches(host, uriHost, allowWildcards))
355                {
356                  return true;
357                }
358              }
359
360              hasAuthoritativeSubjectAlternativeName = true;
361              break;
362
363            case 7: // iPAddress
364              final String ipAddressString = (String) l.get(1);
365              certInfo.append(" iPAddress='");
366              certInfo.append(ipAddressString);
367              certInfo.append('\'');
368
369              if ((hostInetAddress != null) &&
370                   ipAddressMatches(hostInetAddress, ipAddressString))
371              {
372                return true;
373              }
374
375              hasAuthoritativeSubjectAlternativeName = true;
376              break;
377          }
378        }
379      }
380    }
381    catch (final Exception e)
382    {
383      Debug.debugException(e);
384    }
385
386
387    // If we found an authoritative subject alternative name and we should not
388    // check the subject DN to see if it contains a CN attribute, then indicate
389    // that we didn't find a match.
390    if (hasAuthoritativeSubjectAlternativeName &&
391         (! checkCNWhenSubjectAltNameIsPresent))
392    {
393      return false;
394    }
395
396
397    // Look for any CN attributes in the certificate subject.
398    final String subjectDNString =
399         certificate.getSubjectX500Principal().getName(X500Principal.RFC2253);
400    certInfo.append("subject='");
401    certInfo.append(subjectDNString);
402    certInfo.append('\'');
403
404    try
405    {
406      final DN subjectDN = new DN(subjectDNString);
407      for (final RDN rdn : subjectDN.getRDNs())
408      {
409        final String[] names  = rdn.getAttributeNames();
410        final String[] values = rdn.getAttributeValues();
411        for (int i=0; i < names.length; i++)
412        {
413          final String lowerName = StaticUtils.toLowerCase(names[i]);
414          if (lowerName.equals("cn") || lowerName.equals("commonname") ||
415              lowerName.equals("2.5.4.3"))
416
417          {
418            final String cnValue = values[i];
419            if (IPAddressArgumentValueValidator.
420                 isValidNumericIPAddress(cnValue))
421            {
422              if ((hostInetAddress != null) &&
423                   ipAddressMatches(hostInetAddress, cnValue))
424              {
425                return true;
426              }
427            }
428            else
429            {
430              if (hostnameMatches(host, cnValue, allowWildcards))
431              {
432                return true;
433              }
434            }
435          }
436        }
437      }
438    }
439    catch (final Exception e)
440    {
441      // This shouldn't happen for a well-formed certificate subject, but we
442      // have to handle it anyway.
443      Debug.debugException(e);
444    }
445
446
447    // If we've gotten here, then we can't consider the hostname a match.
448    return false;
449  }
450
451
452
453  /**
454   * Determines whether the provided client hostname matches the given
455   * hostname from the certificate.
456   *
457   * @param  clientHostname
458   *              The hostname that the client used when establishing the
459   *              connection.
460   * @param  certificateHostname
461   *              A hostname obtained from the certificate.
462   * @param  allowWildcards
463   *              Indicates whether to allow wildcard certificates that contain
464   *              an asterisk in the leftmost component of a hostname in the
465   *              dNSName or uniformResourceIdentifier of the subject
466   *              alternative name extension, or in the CN attribute of the
467   *              subject DN.
468   *
469   * @return  {@code true} if the client hostname is considered a match for the
470   *          certificate hostname, or {@code false} if not.
471   */
472  private static boolean hostnameMatches(@NotNull final String clientHostname,
473                              @NotNull final String certificateHostname,
474                              final boolean allowWildcards)
475  {
476    // If the provided certificate hostname does not contain any asterisks,
477    // then we just need to do a case-insensitive match.
478    if (! certificateHostname.contains("*"))
479    {
480      return clientHostname.equalsIgnoreCase(certificateHostname);
481    }
482
483
484    // The certificate hostname contains at least one wildcard.  See if that's
485    // allowed.
486    if (! allowWildcards)
487    {
488      return false;
489    }
490
491
492    // Get the first component and the remainder for both the client and
493    // certificate hostnames.  If the remainder doesn't match, then it's not a
494    // match.
495    final ObjectPair<String,String> clientFirstComponentAndRemainder =
496         getFirstComponentAndRemainder(clientHostname);
497    final ObjectPair<String,String> certificateFirstComponentAndRemainder =
498         getFirstComponentAndRemainder(certificateHostname);
499    if (! clientFirstComponentAndRemainder.getSecond().equalsIgnoreCase(
500         certificateFirstComponentAndRemainder.getSecond()))
501    {
502      return false;
503    }
504
505
506    // If the first component of the certificate hostname is just an asterisk,
507    // then we can consider it a match.
508    final String certificateFirstComponent =
509         certificateFirstComponentAndRemainder.getFirst();
510    if (certificateFirstComponent.equals("*"))
511    {
512      return true;
513    }
514
515
516    // The filter has wildcard and non-wildcard components.  At this point, the
517    // easiest thing to do is to try to create a substring filter to get the
518    // individual components of the filter.
519    final Filter filter;
520    try
521    {
522      filter = Filter.create("(hostname=" + certificateFirstComponent + ')');
523      if (filter.getFilterType() != Filter.FILTER_TYPE_SUBSTRING)
524      {
525        return false;
526      }
527    }
528    catch (final Exception e)
529    {
530      Debug.debugException(e);
531      return false;
532    }
533
534
535    return CaseIgnoreStringMatchingRule.getInstance().matchesSubstring(
536         new ASN1OctetString(clientFirstComponentAndRemainder.getFirst()),
537         filter.getRawSubInitialValue(),
538         filter.getRawSubAnyValues(), filter.getRawSubFinalValue());
539  }
540
541
542
543  /**
544   * Separates the provided address into the leftmost component (everything up
545   * to the first period) and the remainder (everything else, including the
546   * first period).  If the provided address does not contain any periods, then
547   * the leftmost component will be the entire value and the remainder will be
548   * an empty string.
549   *
550   * @param  address  The address to be separated into the leftmost component
551   *                  and the remainder.  It must not be {@code null}.
552   *
553   * @return  An object pair in which the first element is the leftmost
554   *          component of the provided address and the second element is the
555   *          remainder of the address.
556   */
557  @NotNull()
558  private static ObjectPair<String,String> getFirstComponentAndRemainder(
559                                                @NotNull final String address)
560  {
561    final int periodPos = address.indexOf('.');
562    if (periodPos < 0)
563    {
564      return new ObjectPair<>(address, "");
565    }
566    else
567    {
568      return new ObjectPair<>(address.substring(0, periodPos),
569           address.substring(periodPos));
570    }
571  }
572
573
574
575  /**
576   * Determines whether the provided client IP address matches the IP address
577   * represented by the provided string.
578   *
579   * @param  clientIPAddress
580   *              The IP address that the client used when establishing the
581   *              connection.
582   * @param  certificateIPAddressString
583   *              The string representation of an IP address obtained from the
584   *              certificate.
585   *
586   * @return  {@code true} if the client hostname is considered a match for the
587   *          certificate hostname, or {@code false} if not.
588   */
589  private static boolean ipAddressMatches(
590                              @NotNull final InetAddress clientIPAddress,
591                              @NotNull final String certificateIPAddressString)
592  {
593    final InetAddress certificateIPAddress;
594    try
595    {
596      certificateIPAddress = LDAPConnectionOptions.DEFAULT_NAME_RESOLVER.
597           getByName(certificateIPAddressString);
598    }
599    catch (final Exception e)
600    {
601      Debug.debugException(e);
602      return false;
603    }
604
605    return clientIPAddress.equals(certificateIPAddress);
606  }
607
608
609
610  /**
611   * Extracts the host from the URI with the given string representation.  Note
612   * that the Java URI parser doesn't like hostnames that have wildcards, so we
613   * have to handle them specially.
614   *
615   * @param  uriString  The string representation of the URI to parse.  It must
616   *                    not be {@code null}.
617   *
618   * @return  The host extracted from the provided URI, or {@code null} if none
619   *          is available (e.g., because the URI is malformed).
620   */
621  @Nullable()
622  private static String getHostFromURI(@NotNull final String uriString)
623  {
624    final URI uri;
625    try
626    {
627      uri = new URI(uriString);
628    }
629    catch (final Exception e)
630    {
631      Debug.debugException(e);
632      return null;
633    }
634
635    final String uriHost = uri.getHost();
636    if (uriHost != null)
637    {
638      return uriHost;
639    }
640
641
642    // Java's URI code can't handle hosts with wildcards.  See if the provided
643    // URI string looks like it might contain a wildcard.  If not, then just
644    // return null.
645    if (! uriString.contains("*"))
646    {
647      return null;
648    }
649
650
651    // If Java was at least able to parse the scheme, and if the URI starts with
652    // that scheme, then we can go ahead with our own parsing attempt.
653    final String scheme = uri.getScheme();
654    if ((scheme == null) || scheme.isEmpty() ||
655         (! uriString.toLowerCase().startsWith(scheme)))
656    {
657      return null;
658    }
659
660
661    // Strip the scheme from the beginning of the URI.  Note that the scheme
662    // probably won't contain the "://", so strip that separately.
663    String paredDownURI = uriString.substring(scheme.length());
664    if (paredDownURI.startsWith("://"))
665    {
666      paredDownURI = paredDownURI.substring(3);
667    }
668
669
670    // If the pared down URI contains a slash (which would separate the hostport
671    // section from the path), then strip that off and everything after it.
672    final int slashPos = paredDownURI.indexOf('/');
673    if (slashPos >= 0)
674    {
675      paredDownURI = paredDownURI.substring(0, slashPos);
676    }
677
678
679    // If the pared down URI contains a colon (which would separate the host
680    // from the port), then strip that off and everything after it.
681    final int colonPos = paredDownURI.indexOf(':');
682    if (colonPos >= 0)
683    {
684      paredDownURI = paredDownURI.substring(0, colonPos);
685    }
686
687
688    // If there's anything left, then it should be the host.
689    if (! paredDownURI.isEmpty())
690    {
691      return paredDownURI;
692    }
693
694    return null;
695  }
696}