#!/usr/bin/env python
#
# Copyright 2005-2019 The Mumble Developers. All rights reserved.
# Use of this source code is governed by a BSD-style license
# that can be found in the LICENSE file at the root of the
# Mumble source tree or at <https://www.mumble.info/LICENSE>.
#
#
# cipherinfo.py
# Generate static TLS cipher information for Mumble.

from __future__ import (unicode_literals, print_function, division)

import json
import re
import subprocess

try:
	from urllib2 import urlopen
except:
	from urllib.request import urlopen

from xml.dom import minidom

IETF_TLS_PARAMETERS_WWW = "https://www.ietf.org/assignments/tls-parameters/tls-parameters.xml"

def integerFromIdent(ident):
	'''
		Parses a TLS cipher suite identifier from the IETF TLS cipher suite registry.
		The cipher suite identifier is represented as a string of hex-encoded byte values
		separated by commas.

		This function consumes the byte sequence and converts it into an unsigned integer.
	'''
	hexVals = ident.split(',')
	nbytes = len(hexVals)
	if nbytes > 3:
		raise Exception("unexpected amount of bytes")
	outVal = 0
	for idx, val in enumerate(hexVals):
		intVal = int(val, base=16)
		outVal |= ((intVal & 0xff) << (((nbytes - 1) - idx) * 8))
	return outVal

def rfcNameLut():
	lut = {}

	# Auto-generate from IETF_TLS_PARAMETERS_WWW
	u = urlopen(IETF_TLS_PARAMETERS_WWW)
	s = u.read()
	s = s.decode('utf-8')
	u.close()

	dom = minidom.parseString(s)
	registries = dom.getElementsByTagName("registry")
	for registry in registries:
		ident = registry.getAttribute("id")
		if ident == "tls-parameters-4":
			records = registry.getElementsByTagName("record")
			for record in records:
				value = record.getElementsByTagName("value")[0].childNodes[0].nodeValue.strip()
				description = record.getElementsByTagName("description")[0].childNodes[0].nodeValue.strip()
				# Skip free-form informational entries that use ranges (-) and *
				# in their value.
				if re.match("^[0xA-Z1-9,]*$", value) is None:
					continue
				lut[value] = description

	##########################################################################
	# Obsolete SSLv2 cipher suites from RFC 4346, Appendix E:
	##########################################################################
	# TLS_RC4_128_WITH_MD5                  # 0x01, 0x00, 0x80
	lut["0x01,0x00,0x80"] = "TLS_RC4_128_WITH_MD5"
	# TLS_RC4_128_EXPORT40_WITH_MD5         # 0x02, 0x00, 0x80
	lut["0x02,0x00,0x80"] = "TLS_RC4_128_EXPORT40_WITH_MD5"
	# TLS_RC2_CBC_128_CBC_WITH_MD5          # 0x03, 0x00, 0x80
	lut["0x03,0x00,0x80"] = "TLS_RC2_CBC_128_CBC_WITH_MD5"
	# TLS_RC2_CBC_128_CBC_EXPORT40_WITH_MD5 # 0x04, 0x00, 0x80
	lut["0x04,0x00,0x80"] = "TLS_RC2_CBC_128_CBC_EXPORT40_WITH_MD5"
	# TLS_IDEA_128_CBC_WITH_MD5             # 0x05, 0x00, 0x80
	lut["0x05,0x00,0x80"] = "TLS_IDEA_128_CBC_WITH_MD5"
	# TLS_DES_64_CBC_WITH_MD5               # 0x06, 0x00, 0x40
	lut["0x06,0x00,0x40"] = "TLS_DES_64_CBC_WITH_MD5"
	# TLS_DES_192_EDE3_CBC_WITH_MD5         # 0x07, 0x00, 0xC0
	lut["0x07,0x00,0xC0"] = "TLS_DES_192_EDE3_CBC_WITH_MD5"

	return lut

def opensslCiphersOutput():
	p = subprocess.Popen(['openssl', 'ciphers', '-V', 'ALL'], stdout=subprocess.PIPE)
	stdout, stderr = p.communicate()
	if stdout is not None:
		stdout = stdout.decode('utf-8')
	if stderr is not None:
		stderr = stderr.decode('utf-8')
	if p.returncode != 0:
		raise Exception('"openssl ciphers" failed: %s', stderr)
	return stdout

def extract(splat):
	if len(splat) < 8:
		splat.extend([''])
	return splat

def Cstr(val):
	if val is None:
		return 'NULL'
	return '"{0}"'.format(val)

def CPPbool(val):
	if val is True:
		return 'true'
	return 'false'

def main():
	added_ids = []
	output = []
	lut = rfcNameLut()
	ciphers = opensslCiphersOutput()
	for line in ciphers.split('\n'):
		if len(line) == 0:
			continue

		line = line.replace(' - ', ' ')

		line = line.replace('Kx=', '')
		line = line.replace('Au=', '')
		line = line.replace('Enc=', '')
		line = line.replace('Mac=', '')
		line = line.replace('(', '_')
		line = line.replace(')', '')
		line = line.replace('/', '_')

		tabline = re.sub('\ +', '', line, 1)
		tabline = re.sub('\ +', '\t', tabline)
		splat = tabline.split('\t')

		ident, osslname, minproto, kx, au, enc, mac, exp = extract(splat)

		# Look up the RFC name of this cipher suite.
		if ident in lut:
			rfcname = lut[ident]
		else:
			raise Exception('missing rfc_name in lut for %s' % ident)

		# Normalize kx, au, enc and mac.
		if 'None' in au:
			au = 'Anonymous'

		enc = enc.upper()

		if 'AESGCM' in enc:
			enc = enc.replace('GCM', '')
			enc = enc + '_GCM'

		if 'GCM' in rfcname and not 'GCM' in enc:
			enc = enc + '_GCM'
		elif 'EDE_CBC' in rfcname:
			enc = enc + '_EDE_CBC'
		elif 'CBC' in rfcname:
			enc = enc + '_CBC'
		elif 'CCM_8' in rfcname:
			enc = enc + '_CCM_8'
		elif 'CCM' in rfcname:
			enc = enc + '_CCM'

		if 'ECDHE' in osslname and not 'ECDHE' in kx:
			kx = kx.replace('ECDH', 'ECDHE')
		if 'ECDHE' in osslname and not 'ECDHE' in au:
			au = au.replace('ECDH', 'ECDHE')

		if 'DHE' in osslname and not 'DHE' in kx:
			kx = kx.replace('DH', 'DHE')
		if 'DHE' in osslname and not 'DHE' in au:
			au = au.replace('DH', 'DHE')
		if 'EDH' in osslname and not 'EDH' in kx:
			kx = kx.replace('DH', 'DHE')
		if 'EDH' in osslname and not 'EDH' in au:
			au = au.replace('DH', 'DHE')

		if mac != 'AEAD':
			mac = 'HMAC-' + mac

		# Validate macs
		valid_macs = ['AEAD', 'HMAC-MD5', 'HMAC-SHA1', 'HMAC-SHA256', 'HMAC-SHA384']
		if mac not in valid_macs:
			raise Exception('invalid mac found: %s' % mac)

		# Use key exchange names from the RFCs, but also create
		# verbose key exchange names for export ciphers.
		match = re.match('^(TLS_|SSL_)(.*)_WITH.*$', rfcname)
		valid_rfc_kex = [
			"ECDHE_RSA",
			"ECDHE_ECDSA",
			"SRP_SHA_DSS",
			"SRP_SHA_RSA",
			"SRP_SHA",
			"DHE_DSS",
			"DHE_RSA",
			"ECDH_anon",
			"DH_anon",
			"DH_DSS",
			"DH_RSA",
			"ECDH_RSA",
			"ECDH_ECDSA",
			"RSA",
			"PSK",
		]
		valid_export_rfc_kex = [
			"DHE_RSA_EXPORT",
			"DHE_DSS_EXPORT",
			"DHE_DSS_EXPORT",
			"DH_anon_EXPORT",
			"RSA_EXPORT",
		]
		skip_rfc_kex = [
			"IDEA_128_CBC",
			"RC2_CBC_128_CBC",
			"RC4_128",
			"DES_192_EDE3_CBC",
			"DES_64_CBC",
			"IDEA_128_CBC",
			"RC2_CBC_128_CBC_EXPORT40",
			"RC4_128_EXPORT40"
		]
		if match is not None:
			rfc_kex = match.groups()[1]
			rfc_verbose_kex = rfc_kex

			if rfc_kex in skip_rfc_kex:
				rfc_kex = kx
				rfc_verbose_kex = kx
			elif rfc_kex in valid_rfc_kex:
				pass
			elif rfc_kex in valid_export_rfc_kex:
				if rfc_kex == 'DHE_RSA_EXPORT':
					rfc_verbose_kex = 'DHE_512_RSA_EXPORT'
				elif rfc_kex == 'DHE_DSS_EXPORT':
					rfc_verbose_kex = 'DHE_512_DSS_EXPORT'
				elif rfc_kex == 'DH_anon_EXPORT':
					rfc_verbose_kex = 'DH_anon_512_EXPORT'
				elif rfc_kex == 'RSA_EXPORT':
					rfc_verbose_kex = 'RSA_512_EXPORT'
				else:
					raise Exception('missing check for rfc_kex?')
			else:
				raise Exception('bad rfc_kex found: %s' % rfc_kex)

		pfs = False
		if rfc_verbose_kex == 'ECDHE_RSA':
			pfs = True
		elif rfc_verbose_kex == 'ECDHE_ECDSA':
			pfs = True
		elif rfc_verbose_kex == 'DHE_RSA':
			pfs = True
		elif rfc_verbose_kex == 'DHE_DSS':
			pfs = True
		elif rfc_verbose_kex == 'DHE_512_RSA_EXPORT':
			pfs = True
		elif rfc_verbose_kex == 'DHE_512_DSS_EXPORT':
			pfs = True
		# XXX: should SRP be marked as forward_secret?

		output.append({
			'sort_order': integerFromIdent(ident),
			'identifier': ident,
			'openssl_name': osslname,
			'rfc_name': rfcname,
			'minimum_protocol': minproto,
			'key_exchange': rfc_kex,
			'key_exchange_verbose': rfc_verbose_kex,
			'openssl_key_exchange': kx,
			'openssl_authentication': au,
			'key_exchange': rfc_kex,
			'encryption': enc,
			'message_authentication': mac,
			'export': exp == 'export',
			'forward_secret': pfs
		})
		added_ids.append(ident)

	# Add everything we missed from OpenSSL...
	include_extras = False
	if include_extras:
		for key in lut.keys():
			if not key in added_ids:
				output.append({
					'sort_order': integerFromIdent(key),
					'identifier': key,
					'openssl_name': None,
					'rfc_name': lut[key],
					'minimum_protocol': None,
					'key_exchange': None,
					'key_exchange_verbose': None,
					'openssl_key_exchange': None,
					'openssl_authentication': None,
					'key_exchange': None,
					'encryption': None,
					'message_authentication': None,
					'export': None,
					'forward_secret': None
				})
	sortedOutput = sorted(output, key=lambda meta: meta['sort_order'])

	output_c = True
	if output_c:
		print('// Automatically generated by "generate-cipherinfo.py". DO NOT EDIT BY HAND.')
		print('//')
		print('// I also agree to have manually vetted this file for correctness.')
		print('//')
		print('// If I do not agree, I will not have removed the line above saying')
		print('// otherwise. Nor will I have removed the line below this one which')
		print('// will cause a preprocessor error. Oops!')
		print('#error Please verify this file is correct')
		print('static const SSLCipherInfo cipher_info_lookup_table[] = {')
		for entry in sortedOutput:
			print('\t{')
			print('\t\t// openssl_name')
			print('\t\t{0},'.format(Cstr(entry["openssl_name"])))
			print('\t\t// rfc_name')
			print('\t\t{0},'.format(Cstr(entry["rfc_name"])))
			print('\t\t// encryption')
			print('\t\t{0},'.format(Cstr(entry["encryption"])))
			print('\t\t// key_exchange_verbose. kx = {0}, au = {1}'.format(entry["openssl_key_exchange"], entry["openssl_authentication"]))
			print('\t\t{0},'.format(Cstr(entry["key_exchange_verbose"])))
			print('\t\t// forward secret')
			print('\t\t{0},'.format(CPPbool(entry["forward_secret"])))
			print('\t\t// message authentication')
			print('\t\t{0},'.format(Cstr(entry["message_authentication"])))
			print('\t},')
		print('};')
	else:
		print(json.dumps(sortedOutput, sort_keys=True, indent=4, separators=(',', ': ')))

if __name__ == '__main__':
	main()
