Browse Source

Add CertificateHostChecker class.

This will replace the two or three kludgy methods in CertificateManager
that currently validate hostnames. In the process it fixes some fun
problems that could arise if the subject of a certificate contained
regex quote sequences (\Q...\E).

Issue #806
pull/808/head
Chris Smith 7 years ago
parent
commit
7e706dcf01

+ 130
- 0
src/main/java/com/dmdirc/tls/CertificateHostChecker.java View File

@@ -0,0 +1,130 @@
1
+package com.dmdirc.tls;
2
+
3
+import java.security.cert.CertificateParsingException;
4
+import java.security.cert.X509Certificate;
5
+import java.util.Arrays;
6
+import java.util.HashSet;
7
+import java.util.Optional;
8
+import java.util.Set;
9
+import java.util.regex.Pattern;
10
+import java.util.stream.Collectors;
11
+import javax.naming.InvalidNameException;
12
+import javax.naming.ldap.LdapName;
13
+import javax.naming.ldap.Rdn;
14
+
15
+/**
16
+ * Checks that the host we're connecting to is one specified in a certificate.
17
+ *
18
+ * <p>Certificates match if any of their subjectAlternateName extensions, or the subject's common name, matches
19
+ * the host we're connecting to.
20
+ */
21
+public class CertificateHostChecker {
22
+
23
+    /**
24
+     * Checks if the specified certificate is valid for the given hostname.
25
+     *
26
+     * @param certificate The certificate to check.
27
+     * @param host The hostname that was connected to.
28
+     * @return True if the certificate covers the given hostname, false otherwise.
29
+     */
30
+    public boolean isValidFor(final X509Certificate certificate, final String host) {
31
+        return getAllNames(certificate).stream().anyMatch(name -> matches(name, host));
32
+    }
33
+
34
+    /**
35
+     * Checks if the specified name matches against the host, taking into account wildcards.
36
+     *
37
+     * <p>Hosts are compared by splitting them into domain parts (test.dmdirc.com becomes [test, dmdirc, com]) and
38
+     * comparing each part against the corresponding part of the supplied name. Wildcards may only expand within a
39
+     * single part (i.e. *.example.com cannot match foo.bar.example.com).
40
+     *
41
+     * @param name The name to check.
42
+     * @param host The host to check it against.
43
+     * @return True if the name matches the host; false otherwise.
44
+     */
45
+    private boolean matches(final String name, final String host) {
46
+        final String[] nameParts = name.split("\\.");
47
+        final String[] hostParts = host.split("\\.");
48
+
49
+        if (nameParts.length != hostParts.length) {
50
+            return false;
51
+        }
52
+
53
+        for (int i = 0; i < nameParts.length; i++) {
54
+            if (!partMatches(nameParts[i], hostParts[i])) {
55
+                return false;
56
+            }
57
+        }
58
+
59
+        return true;
60
+    }
61
+
62
+    /**
63
+     * Checks if the specified part of the name matches the corresponding part of the host, taking into account
64
+     * wildcards.
65
+     *
66
+     * @param namePart The part of the name to be expanded and checked.
67
+     * @param hostPart The corresponding part of the host to check the name against.
68
+     * @return True if the name and host parts match; false otherwise.
69
+     */
70
+    private boolean partMatches(final String namePart, final String hostPart) {
71
+        return namePart.equals("*") || hostPart.toLowerCase().matches(
72
+                Arrays.stream(namePart.toLowerCase().split("\\*"))
73
+                        .map(Pattern::quote)
74
+                        .collect(Collectors.joining(".*")));
75
+    }
76
+
77
+    /**
78
+     * Returns all names for which a certificate is valid.
79
+     *
80
+     * @param cert The certificate to read.
81
+     * @return The names which the certificate covers.
82
+     */
83
+    private Set<String> getAllNames(final X509Certificate cert) {
84
+        final Set<String> names = new HashSet<>();
85
+        getCommonName(cert).ifPresent(names::add);
86
+        getSubjectAlternateNames(cert).ifPresent(names::addAll);
87
+        return names;
88
+    }
89
+
90
+    /**
91
+     * Reads the common name (CN) from the certificate's subject.
92
+     *
93
+     * @param cert The certificate to read.
94
+     * @return The common name of the certificate, if present.
95
+     */
96
+    private Optional<String> getCommonName(final X509Certificate cert) {
97
+        try {
98
+            final LdapName name = new LdapName(cert.getSubjectX500Principal().getName());
99
+            return name.getRdns().stream()
100
+                    .filter(rdn -> "CN".equalsIgnoreCase(rdn.getType()))
101
+                    .map(Rdn::getValue)
102
+                    .map(Object::toString)
103
+                    .findFirst();
104
+        } catch (InvalidNameException ex) {
105
+            return Optional.empty();
106
+        }
107
+    }
108
+
109
+    /**
110
+     * Reads all the subjectAlternateName extensions from the certificate.
111
+     *
112
+     * @param cert The certificate to read.
113
+     * @return The (possibly empty) set of subject alternate names.
114
+     */
115
+    private Optional<Set<String>> getSubjectAlternateNames(final X509Certificate cert) {
116
+        try {
117
+            return Optional.ofNullable(cert.getSubjectAlternativeNames())
118
+                    .map(sans -> sans.stream()
119
+                            // Filter for GeneralName type 2 (dNSName)
120
+                            .filter(san -> (Integer) san.get(0) == 2)
121
+                            // Get the string representation of the value of those names
122
+                            .map(san -> san.get(1))
123
+                            .map(Object::toString)
124
+                            .collect(Collectors.toSet()));
125
+        } catch (CertificateParsingException e) {
126
+            return Optional.empty();
127
+        }
128
+    }
129
+
130
+}

+ 86
- 0
src/test/java/com/dmdirc/tls/CertificateHostCheckerTest.java View File

@@ -0,0 +1,86 @@
1
+package com.dmdirc.tls;
2
+
3
+import java.io.IOException;
4
+import java.io.InputStream;
5
+import java.security.GeneralSecurityException;
6
+import java.security.KeyStore;
7
+import java.security.cert.X509Certificate;
8
+import org.junit.Before;
9
+import org.junit.Test;
10
+
11
+import static org.junit.Assert.assertFalse;
12
+import static org.junit.Assert.assertTrue;
13
+
14
+/**
15
+ * Tests for {@link CertificateHostChecker}.
16
+ *
17
+ * <p>These tests use several certificates stored in a keystore. They were generated using:
18
+ *
19
+ * <pre>
20
+ * keytool -genkey -validity 18250 -keystore "keystore.ks" -storepass "dmdirc" -keypass "dmdirc" -alias "name_cn_only" -dname "CN=test.example.com, O=DMDirc, C=GB"
21
+ * keytool -genkey -validity 18250 -keystore "keystore.ks" -storepass "dmdirc" -keypass "dmdirc" -alias "name_cn_wildcard" -dname "CN=*.example.com, O=DMDirc, C=GB"
22
+ * keytool -genkey -validity 18250 -keystore "keystore.ks" -storepass "dmdirc" -keypass "dmdirc" -alias "name_san_dns" -dname "CN=other.example.com, O=DMDirc, C=GB" -ext SAN=dns:test.example.com
23
+ * keytool -genkey -validity 18250 -keystore "keystore.ks" -storepass "dmdirc" -keypass "dmdirc" -alias "name_san_dns_multiple" -dname "CN=other.example.com, O=DMDirc, C=GB" -ext SAN=dns:foo.example.com,dns:test.example.com
24
+ * </pre>
25
+ */
26
+public class CertificateHostCheckerTest {
27
+
28
+    private CertificateHostChecker checker;
29
+
30
+    @Before
31
+    public void setup() {
32
+        checker = new CertificateHostChecker();
33
+    }
34
+
35
+    @Test
36
+    public void testBasicCn() throws GeneralSecurityException, IOException {
37
+        final X509Certificate certificate = getCertificate("name_cn_only");
38
+        assertTrue(checker.isValidFor(certificate, "test.example.com"));
39
+        assertTrue(checker.isValidFor(certificate, "TEsT.example.com"));
40
+        assertFalse(checker.isValidFor(certificate, "foo.example.com"));
41
+        assertFalse(checker.isValidFor(certificate, "test.example.org"));
42
+        assertFalse(checker.isValidFor(certificate, "foo.test.example.com"));
43
+    }
44
+
45
+    @Test
46
+    public void testWildcardCn() throws GeneralSecurityException, IOException {
47
+        final X509Certificate certificate = getCertificate("name_cn_wildcard");
48
+        assertTrue(checker.isValidFor(certificate, "test.example.com"));
49
+        assertTrue(checker.isValidFor(certificate, "TEsT.example.com"));
50
+        assertTrue(checker.isValidFor(certificate, "foo.example.com"));
51
+        assertFalse(checker.isValidFor(certificate, "test.example.org"));
52
+        assertFalse(checker.isValidFor(certificate, "foo.test.example.com"));
53
+    }
54
+
55
+    @Test
56
+    public void testSanDns() throws GeneralSecurityException, IOException {
57
+        final X509Certificate certificate = getCertificate("name_san_dns");
58
+        assertTrue(checker.isValidFor(certificate, "test.example.com"));
59
+        assertTrue(checker.isValidFor(certificate, "TEsT.example.com"));
60
+        assertTrue(checker.isValidFor(certificate, "other.example.com"));
61
+        assertFalse(checker.isValidFor(certificate, "foo.example.com"));
62
+        assertFalse(checker.isValidFor(certificate, "test.example.org"));
63
+        assertFalse(checker.isValidFor(certificate, "foo.test.example.com"));
64
+    }
65
+
66
+    @Test
67
+    public void testSanDnsMultiple() throws GeneralSecurityException, IOException {
68
+        final X509Certificate certificate = getCertificate("name_san_dns_multiple");
69
+        assertTrue(checker.isValidFor(certificate, "test.example.com"));
70
+        assertTrue(checker.isValidFor(certificate, "TEsT.example.com"));
71
+        assertTrue(checker.isValidFor(certificate, "other.example.com"));
72
+        assertTrue(checker.isValidFor(certificate, "foo.example.com"));
73
+        assertFalse(checker.isValidFor(certificate, "test.foo.example.org"));
74
+        assertFalse(checker.isValidFor(certificate, "test.example.org"));
75
+        assertFalse(checker.isValidFor(certificate, "foo.test.example.com"));
76
+    }
77
+
78
+    private X509Certificate getCertificate(final String name) throws GeneralSecurityException, IOException {
79
+        try (InputStream is = getClass().getResourceAsStream("keystore.ks")) {
80
+            final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
81
+            keyStore.load(is, "dmdirc".toCharArray());
82
+            return (X509Certificate) keyStore.getCertificate(name);
83
+        }
84
+    }
85
+
86
+}

BIN
src/test/resources/com/dmdirc/tls/keystore.ks View File


Loading…
Cancel
Save