Classify ciphers based on OpenSSL information
[tls-check.git] / tls-check
index 9d84f4299a679b9f90006b922e679e47950d86a0..27651d53855b9a36b970c52434143898eb4a3126 100755 (executable)
--- a/tls-check
+++ b/tls-check
@@ -1,6 +1,6 @@
 #!/usr/bin/python3
-import subprocess, sys, argparse
-from collections import OrderedDict
+import subprocess, sys, argparse, time, re
+from collections import OrderedDict, namedtuple
 from enum import Enum
 
 # progress bar
@@ -35,10 +35,11 @@ def finish_progress():
     w, h = terminal_size()
     sys.stdout.write('\r'+(' '*w)+'\r')
     sys.stdout.flush()
+    
 
 # cipher check
-def list_ciphers():
-    ciphers = subprocess.check_output(["openssl", "ciphers", "ALL:COMPLEMENTOFALL"]).decode('UTF-8').strip()
+def list_ciphers(spec="ALL:COMPLEMENTOFALL"):
+    ciphers = subprocess.check_output(["openssl", "ciphers", spec]).decode('UTF-8').strip()
     return ciphers.split(':')
 
 def test_cipher(host, port, protocol, cipher = None, options=[]):
@@ -52,7 +53,7 @@ def test_cipher(host, port, protocol, cipher = None, options=[]):
     else:
         return True
 
-def test_protocol(host, port, protocol, ciphers, base_frac, options=[]):
+def test_protocol(host, port, protocol, ciphers, base_frac, wait_time=0, options=[]):
     if test_cipher(host, port, protocol, options=options):
         # the protocol is supported
         results = OrderedDict()
@@ -60,26 +61,90 @@ def test_protocol(host, port, protocol, ciphers, base_frac, options=[]):
             cipher = ciphers[i]
             print_progress(protocol+" "+cipher, base_frac+[(i, len(ciphers))])
             results[cipher] = test_cipher(host, port, protocol, cipher, options)
+            # throttle
+            time.sleep(wait_time/1000)
         return results
     else:
         # it is not supported
         return None
 
-def test_host(host, port, options=[]):
+def test_host(host, port, wait_time=0, options=[]):
     ciphers = list_ciphers()
     results = OrderedDict()
     protocols = ('ssl2', 'ssl3', 'tls1', 'tls1_1', 'tls1_2')
     for i in range(len(protocols)):
         protocol = protocols[i]
         print_progress(protocol, [(i, len(protocols))])
-        results[protocol] = test_protocol(host, port, protocol, ciphers, [(i, len(protocols))], options)
+        results[protocol] = test_protocol(host, port, protocol, ciphers, [(i, len(protocols))], wait_time, options)
     finish_progress()
     return results
 
+# cipher classification
+class CipherStrength(Enum):
+    unknown = -1
+    exp = 0
+    low = 1
+    medium = 2
+    high = 3
+CipherProps = namedtuple('CipherProps', 'bits, strength, isPfs')
+
+class CipherPropsProvider:
+    def __init__(self):
+        self.exp = set(list_ciphers("EXP"))
+        self.low = set(list_ciphers("LOW"))
+        self.medium = set(list_ciphers("MEDIUM"))
+        self.high = set(list_ciphers("HIGH"))
+        self.props = {}
+    
+    def __getProps(self, cipher):
+        # as OpenSSL about this cipher
+        cipherInfo = subprocess.check_output(["openssl", "ciphers", "-v", cipher]).decode('UTF-8').strip()
+        assert '\n' not in cipherInfo
+        cipherInfoFields = cipherInfo.split()
+        # get # of bits
+        bitMatch = re.match(r'^Enc=[0-9A-Za-z]+\(([0-9]+)\)$', cipherInfoFields[4])
+        if bitMatch is None:
+            raise Exception("Unexpected OpenSSL output: Cannot determine encryption strength from {1}\nComplete output: {0}".format(cipherInfo, cipherInfoFields[4]))
+        bits = int(bitMatch.group(1))
+        # figure out whether the cipher is pfs
+        kxMatch = re.match(r'^Kx=([0-9A-Z/]+)$', cipherInfoFields[2])
+        if kxMatch is None:
+            raise Exception("Unexpected OpenSSL output: Cannot determine key-exchange method from {1}\nComplete output: {0}".format(cipherInfo), cipherInfoFields[2])
+        kx = kxMatch.group(1)
+        isPfs = kx in ('DH', 'ECDH')
+        # determine security level
+        isExp = cipher in self.exp
+        isLow = cipher in self.low
+        isMedium = cipher in self.medium
+        isHigh = cipher in self.high
+        assert isExp+isLow+isMedium+isHigh <= 1, "Cipher is more than one from EXP, LOW, MEDIUM, HIGH"
+        if isExp:
+            strength = CipherStrength.exp
+        elif isLow:
+            strength = CipherStrength.low
+        elif isMedium:
+            strength = CipherStrength.medium
+        elif isHigh:
+            strength = CipherStrength.high
+        else:
+            strength = CipherStrength.unknown
+        # done!
+        return CipherProps(bits=bits, strength=strength, isPfs=isPfs)
+    
+    def getProps(self, cipher):
+        if cipher in self.props:
+            return self.props[cipher]
+        props = self.__getProps(cipher)
+        self.props[cipher] = props
+        return props
+
+# main program
 if __name__ == "__main__":
     parser = argparse.ArgumentParser(description='Check TLS ciphers supported by a host')
     parser.add_argument("--starttls", dest="starttls",
                         help="Use a STARTTLS variant to establish the TLS connection. Possible values include smpt, imap, xmpp.")
+    parser.add_argument("--wait-time", "-t", dest="wait_time", default="10",
+                        help="Time (in ms) to wait between two connections to the server. Default is 10ms.")
     parser.add_argument("host", metavar='HOST[:PORT]',
                         help="The host to check")
     args = parser.parse_args()
@@ -91,15 +156,17 @@ if __name__ == "__main__":
         host = args.host
         port = 443
     
-    # get options
+    # get options and other stuff
+    wait_time = float(args.wait_time)
     options = []
     if args.starttls is not None:
         options += ['-starttls', args.starttls]
     
     # run the test
-    results = test_host(host, port, options)
+    results = test_host(host, port, wait_time, options)
     
     # print the results
+    propsProvider = CipherPropsProvider()
     for protocol, ciphers in results.items():
         print(protocol+":")
         if ciphers is None:
@@ -107,5 +174,6 @@ if __name__ == "__main__":
         else:
             for cipher, supported in ciphers.items():
                 if supported:
-                    print("    "+cipher)
+                    cipherProps = propsProvider.getProps(cipher)
+                    print("    {0} ({1}, {2} bits, {3})".format(cipher, cipherProps.strength.name, cipherProps.bits, "FS" if cipherProps.isPfs else "not FS"))
         print()