Browse Source

Merge pull request #808 from csmith/certs

Add CertificateHostChecker class.
pull/811/head
Greg Holmes 7 years ago
parent
commit
cff387b67a

+ 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 "*".equals(namePart) || 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