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