Classify ciphers based on OpenSSL information
[tls-check.git] / tls-check
1 #!/usr/bin/python3
2 import subprocess, sys, argparse, time, re
3 from collections import OrderedDict, namedtuple
4 from enum import Enum
5
6 # progress bar
7 def terminal_size():
8     import fcntl, termios, struct
9     try:
10         result = fcntl.ioctl(1, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
11     except OSError:
12         # this is not a terminal
13         return 0, 0
14     h, w, hp, wp = struct.unpack('HHHH', result)
15     assert w > 0 and h > 0, "Empty terminal...??"
16     return w, h
17
18 def compute_frac(fracs):
19     frac = 0.0
20     last_frac = 1.0
21     for complete, total in fracs:
22         frac += (complete*last_frac/total)
23         last_frac *= 1/total
24     return frac
25
26 def print_progress(state, fracs):
27     STATE_WIDTH = 30
28     w, h = terminal_size()
29     if w < STATE_WIDTH+10: return # not a (wide enough) terminal
30     bar_width = w-STATE_WIDTH-3
31     hashes = int(bar_width*compute_frac(fracs))
32     sys.stdout.write('\r{0} [{1}{2}]'.format(state[:STATE_WIDTH].ljust(STATE_WIDTH), '#'*hashes, ' '*(bar_width-hashes)))
33     sys.stdout.flush()
34 def finish_progress():
35     w, h = terminal_size()
36     sys.stdout.write('\r'+(' '*w)+'\r')
37     sys.stdout.flush()
38     
39
40 # cipher check
41 def list_ciphers(spec="ALL:COMPLEMENTOFALL"):
42     ciphers = subprocess.check_output(["openssl", "ciphers", spec]).decode('UTF-8').strip()
43     return ciphers.split(':')
44
45 def test_cipher(host, port, protocol, cipher = None, options=[]):
46     try:
47         if cipher is not None:
48             options = ["-cipher", cipher]+options
49         subprocess.check_call(["openssl", "s_client", "-"+protocol, "-connect", host+":"+str(port)]+options,
50                               stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
51     except subprocess.CalledProcessError:
52         return False
53     else:
54         return True
55
56 def test_protocol(host, port, protocol, ciphers, base_frac, wait_time=0, options=[]):
57     if test_cipher(host, port, protocol, options=options):
58         # the protocol is supported
59         results = OrderedDict()
60         for i in range(len(ciphers)):
61             cipher = ciphers[i]
62             print_progress(protocol+" "+cipher, base_frac+[(i, len(ciphers))])
63             results[cipher] = test_cipher(host, port, protocol, cipher, options)
64             # throttle
65             time.sleep(wait_time/1000)
66         return results
67     else:
68         # it is not supported
69         return None
70
71 def test_host(host, port, wait_time=0, options=[]):
72     ciphers = list_ciphers()
73     results = OrderedDict()
74     protocols = ('ssl2', 'ssl3', 'tls1', 'tls1_1', 'tls1_2')
75     for i in range(len(protocols)):
76         protocol = protocols[i]
77         print_progress(protocol, [(i, len(protocols))])
78         results[protocol] = test_protocol(host, port, protocol, ciphers, [(i, len(protocols))], wait_time, options)
79     finish_progress()
80     return results
81
82 # cipher classification
83 class CipherStrength(Enum):
84     unknown = -1
85     exp = 0
86     low = 1
87     medium = 2
88     high = 3
89 CipherProps = namedtuple('CipherProps', 'bits, strength, isPfs')
90
91 class CipherPropsProvider:
92     def __init__(self):
93         self.exp = set(list_ciphers("EXP"))
94         self.low = set(list_ciphers("LOW"))
95         self.medium = set(list_ciphers("MEDIUM"))
96         self.high = set(list_ciphers("HIGH"))
97         self.props = {}
98     
99     def __getProps(self, cipher):
100         # as OpenSSL about this cipher
101         cipherInfo = subprocess.check_output(["openssl", "ciphers", "-v", cipher]).decode('UTF-8').strip()
102         assert '\n' not in cipherInfo
103         cipherInfoFields = cipherInfo.split()
104         # get # of bits
105         bitMatch = re.match(r'^Enc=[0-9A-Za-z]+\(([0-9]+)\)$', cipherInfoFields[4])
106         if bitMatch is None:
107             raise Exception("Unexpected OpenSSL output: Cannot determine encryption strength from {1}\nComplete output: {0}".format(cipherInfo, cipherInfoFields[4]))
108         bits = int(bitMatch.group(1))
109         # figure out whether the cipher is pfs
110         kxMatch = re.match(r'^Kx=([0-9A-Z/]+)$', cipherInfoFields[2])
111         if kxMatch is None:
112             raise Exception("Unexpected OpenSSL output: Cannot determine key-exchange method from {1}\nComplete output: {0}".format(cipherInfo), cipherInfoFields[2])
113         kx = kxMatch.group(1)
114         isPfs = kx in ('DH', 'ECDH')
115         # determine security level
116         isExp = cipher in self.exp
117         isLow = cipher in self.low
118         isMedium = cipher in self.medium
119         isHigh = cipher in self.high
120         assert isExp+isLow+isMedium+isHigh <= 1, "Cipher is more than one from EXP, LOW, MEDIUM, HIGH"
121         if isExp:
122             strength = CipherStrength.exp
123         elif isLow:
124             strength = CipherStrength.low
125         elif isMedium:
126             strength = CipherStrength.medium
127         elif isHigh:
128             strength = CipherStrength.high
129         else:
130             strength = CipherStrength.unknown
131         # done!
132         return CipherProps(bits=bits, strength=strength, isPfs=isPfs)
133     
134     def getProps(self, cipher):
135         if cipher in self.props:
136             return self.props[cipher]
137         props = self.__getProps(cipher)
138         self.props[cipher] = props
139         return props
140
141 # main program
142 if __name__ == "__main__":
143     parser = argparse.ArgumentParser(description='Check TLS ciphers supported by a host')
144     parser.add_argument("--starttls", dest="starttls",
145                         help="Use a STARTTLS variant to establish the TLS connection. Possible values include smpt, imap, xmpp.")
146     parser.add_argument("--wait-time", "-t", dest="wait_time", default="10",
147                         help="Time (in ms) to wait between two connections to the server. Default is 10ms.")
148     parser.add_argument("host", metavar='HOST[:PORT]',
149                         help="The host to check")
150     args = parser.parse_args()
151     
152     # get host, port
153     if ':' in args.host:
154         host, port = args.host.split(':')
155     else:
156         host = args.host
157         port = 443
158     
159     # get options and other stuff
160     wait_time = float(args.wait_time)
161     options = []
162     if args.starttls is not None:
163         options += ['-starttls', args.starttls]
164     
165     # run the test
166     results = test_host(host, port, wait_time, options)
167     
168     # print the results
169     propsProvider = CipherPropsProvider()
170     for protocol, ciphers in results.items():
171         print(protocol+":")
172         if ciphers is None:
173             print("    Is not supported by client or server")
174         else:
175             for cipher, supported in ciphers.items():
176                 if supported:
177                     cipherProps = propsProvider.getProps(cipher)
178                     print("    {0} ({1}, {2} bits, {3})".format(cipher, cipherProps.strength.name, cipherProps.bits, "FS" if cipherProps.isPfs else "not FS"))
179         print()