Skip to content

Commit de8ee7b

Browse files
committed
HBASE-27280 Add mutual authentication support to TLS
1 parent cc4268a commit de8ee7b

File tree

11 files changed

+1740
-37
lines changed

11 files changed

+1740
-37
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.hadoop.hbase.io.crypto.tls;
19+
20+
import java.net.InetAddress;
21+
import java.security.cert.Certificate;
22+
import java.security.cert.CertificateParsingException;
23+
import java.security.cert.X509Certificate;
24+
import java.util.ArrayList;
25+
import java.util.Collection;
26+
import java.util.Collections;
27+
import java.util.List;
28+
import java.util.Locale;
29+
import java.util.NoSuchElementException;
30+
import java.util.Objects;
31+
import java.util.Optional;
32+
import javax.naming.InvalidNameException;
33+
import javax.naming.NamingException;
34+
import javax.naming.directory.Attribute;
35+
import javax.naming.directory.Attributes;
36+
import javax.naming.ldap.LdapName;
37+
import javax.naming.ldap.Rdn;
38+
import javax.net.ssl.HostnameVerifier;
39+
import javax.net.ssl.SSLException;
40+
import javax.net.ssl.SSLPeerUnverifiedException;
41+
import javax.net.ssl.SSLSession;
42+
import javax.security.auth.x500.X500Principal;
43+
import org.apache.yetus.audience.InterfaceAudience;
44+
import org.slf4j.Logger;
45+
import org.slf4j.LoggerFactory;
46+
47+
import org.apache.hbase.thirdparty.com.google.common.net.InetAddresses;
48+
49+
/**
50+
* When enabled in {@link X509Util}, handles verifying that the hostname of a peer matches the
51+
* certificate it presents.
52+
* <p/>
53+
* This file has been copied from the Apache ZooKeeper project.
54+
* @see <a href=
55+
* "https://github.com/apache/zookeeper/blob/5820d10d9dc58c8e12d2e25386fdf92acb360359/zookeeper-server/src/main/java/org/apache/zookeeper/common/ZKHostnameVerifier.java">Base
56+
* revision</a>
57+
*/
58+
@InterfaceAudience.Private
59+
final class HBaseHostnameVerifier implements HostnameVerifier {
60+
61+
private static final Logger LOG = LoggerFactory.getLogger(HBaseHostnameVerifier.class);
62+
63+
/**
64+
* Note: copied from Apache httpclient with some minor modifications. We want host verification,
65+
* but depending on the httpclient jar caused unexplained performance regressions (even when the
66+
* code was not used).
67+
*/
68+
private static final class SubjectName {
69+
70+
static final int DNS = 2;
71+
static final int IP = 7;
72+
73+
private final String value;
74+
private final int type;
75+
76+
SubjectName(final String value, final int type) {
77+
if (type != DNS && type != IP) {
78+
throw new IllegalArgumentException("Invalid type: " + type);
79+
}
80+
this.value = Objects.requireNonNull(value);
81+
this.type = type;
82+
}
83+
84+
public int getType() {
85+
return type;
86+
}
87+
88+
public String getValue() {
89+
return value;
90+
}
91+
92+
@Override
93+
public String toString() {
94+
return value;
95+
}
96+
97+
}
98+
99+
@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+
}

0 commit comments

Comments
 (0)