From d8dac9a330be3514a0ee8437ca020dee968a05ca Mon Sep 17 00:00:00 2001 From: James Prestwood Date: Fri, 26 Jul 2019 14:40:58 -0700 Subject: [PATCH] tools: add tool for iOS mobileconfig conversion This tool will convert an iOS 'mobileconfig' file into the IWD format. The tool only supports PEAP and TTLS networks, including hotspots. It will also parse out any certificate chains found in the mobileconfig file, and verify they lead to a root CA found on the system. If they do, this root CA will be used as the CACert in the provisioning file. --- tools/ios_convert.py | 402 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100755 tools/ios_convert.py diff --git a/tools/ios_convert.py b/tools/ios_convert.py new file mode 100755 index 00000000..9aca5a82 --- /dev/null +++ b/tools/ios_convert.py @@ -0,0 +1,402 @@ +#!/usr/bin/python3 + +import subprocess +import sys +import os +import argparse +from xml.etree import ElementTree + +class Network: + def __init__(self): + self.eap_types = [] + self.outer_id = None + self.inner_eap = None + self.nai_realms = [] + self.cert_path = None + self.is_hotspot = False + self.username = None + self.password = None + self.ssid = None + +def process_eap_config(eap_conf, network): + for m in range(len(eap_conf)): + if eap_conf[m].text == "AcceptEAPTypes": + tarray = eap_conf[m + 1] + for i in range(len(tarray)): + network.eap_types.append(tarray[i].text) + + elif eap_conf[m].text == "TTLSInnerAuthentication": + network.inner_eap = eap_conf[m + 1].text + elif eap_conf[m].text == "OuterIdentity": + network.outer_id = eap_conf[m + 1].text + elif eap_conf[m].text == "UserName" and args.user: + network.username = eap_conf[m + 1].text + elif eap_conf[m].text == "UserPassword" and args.passwd: + network.password = eap_conf[m + 1].text + +def process_payload_array(parray): + network = Network() + + for l in range(len(parray)): + if parray[l].text == "NAIRealmNames": + nai_array = parray[l + 1] + for i in range(len(nai_array)): + network.nai_realms.append(nai_array[i].text) + continue + elif parray[l].text == "IsHotspot": + if parray[l + 1].text == "True": + network.is_hotspot = True + else: + network.is_hotspot = False + elif parray[l].text == "SSID_STR": + network.ssid = parray[l + 1].text + elif parray[l].text == "EAPClientConfiguration": + process_eap_config(parray[l + 1], network) + + return network + +def process_payload(payload): + networks = [] + for k in range(len(payload)): + if payload[k].tag != "dict": + continue + + n = process_payload_array(payload[k]) + if n: + networks.append(n) + + return networks + + +def write_network(network, root_ca_path): + global cert_path + output = "" + eap = None + + # TODO: Handle multiple EAP types? + if len(network.eap_types) < 1: + print("Not configuring open network %s" % network.ssid) + return + + if network.eap_types[0] == '21': + eap = 'TTLS' + elif network.eap_types[0] == '25': + eap = 'PEAP' + + if not eap: + print("TTLS or PEAP config was not found in XML") + return + + if not network.inner_eap: + print("No inner EAP method found in XML") + return + + if network.is_hotspot and len(network.nai_realms) == 0: + print("No NAI realms found in XML") + return + + output = "[Security]\n" + output += "EAP-Method=%s\n" % eap + + # Use OuterIdentity if specified. But if not use "anonymous". Some AP's + # do not like an empty identity packet and will timeout. + if network.outer_id: + output += "EAP-Identity=%s\n" % network.outer_id + else: + output += "EAP-Identity=anonymous\n" + + if root_ca_path: + output += "EAP-%s-CACert=%s\n" % (eap, root_ca_path) + + output += "EAP-%s-Phase2-Method=Tunneled-%s\n" % \ + (eap, network.inner_eap) + + if network.username: + output += "EAP-%s-Phase2-Identity=%s\n" % \ + (eap, network.username) + + if network.password: + output += "EAP-%s-Phase2-Password=%s\n" % \ + (eap, network.password) + + if network.is_hotspot: + conf_file = iwd_dir + '/hotspot/' + \ + os.path.splitext(args.input)[0] + '.conf' + output += "[Hotspot]\n" + output += "NAIRealmNames=" + + for i in range(len(network.nai_realms)): + output += network.nai_realms[i] + + if i < len(network.nai_realms) - 1: + output += ',' + else: + conf_file = iwd_dir + '/' + network.ssid + '.8021x' + + # Some AP's require older protocol versions. There should be no harm in + # setting this all the time. + output += "[EAPoL]\n" + output += "ProtocolVersion=1\n" + + output += "\n" + + print("Provisioning network %s\n" % conf_file) + + if args.verbose: + print(output) + + with open(conf_file, 'w+') as f: + f.write(output) + +def find_root_ca(chain): + def cleanup(certs): + for c in certs: + os.remove(c) + + def parse_cert_chain(file): + certs = [] + in_cert = False + current = "" + f = open(file, 'r') + for l in f: + if '-----BEGIN CERTIFICATE-----' in l: + if in_cert: + print("invalid BEGIN") + exit() + in_cert = True + elif '-----END CERTIFICATE-----' in l: + if not in_cert: + print("invalid END") + exit() + in_cert = False + current += l + certs.append(current) + current = "" + continue + + current += l + + return certs + + def find_root_ca_path(hash): + path = '/etc/ssl/certs/%s.0' % hash + if os.path.exists(path): + return os.path.realpath(path) + + return None + + # + # Parse each cert out of the chain. + # + certs = parse_cert_chain(chain) + files = [] + subjects = [] + self_signed = None + + if len(certs) < 1: + print("No certs found") + exit() + + # + # Write each cert into an intermediate#.pem file, get subject and see if + # any are self-signed (Root CA). If one is self signed, save this file + # to be used later when verifying the chain. + # + for index, c in enumerate(certs): + with open("/tmp/intermediate" + str(index) + ".pem", 'w+') as f: + f.write(c) + + files.append("/tmp/intermediate%d.pem" % index) + + with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(['openssl', 'x509', '-subject', + '-hash', '-issuer_hash', '-noout', '-in', + '/tmp/intermediate%d.pem' % index], + stdout=subprocess.PIPE, stderr=devnull) + + result, err = proc.communicate() + results = result.decode("utf-8").split('\n') + + sub = results[0] + own_hash = results[1] + issuer_hash = results[2] + + # + # Get rid of "depth=#" openssl output + # + sub = sub[sub.index('C ='):].strip() + subjects.append(sub) + + if own_hash == issuer_hash: + self_signed = '/tmp/intermediate%d.pem' % index + + # + # Let openssl verify and print the subject of the cert chain. If there + # is a self signed cert we want openssl to bypass looking in the cert + # store (-no-CApath), and provide this CA (-CAfile). Otherwise, let + # openssl verify the chain as usual. + # + if not self_signed: + with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(['openssl', 'verify', + '-show_chain', '-untrusted', chain, + '/tmp/intermediate0.pem'], + stdout=subprocess.PIPE, stderr=devnull) + else: + with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(['openssl', 'verify', + '-show_chain', '-no-CApath', '-CAfile', + self_signed, '-untrusted', chain, + '/tmp/intermediate0.pem'], + stdout=subprocess.PIPE, stderr=devnull) + + results, err = proc.communicate() + results = results.decode("utf-8").strip().split('\n') + + # + # We only want lines starting with depth= + # + results = [e for e in results if e.startswith('depth=')] + + print("Found %d certs in chain" % len(results)) + + # + # Get rid of prepended "depth=#" + # + ca_sub = results[-1] + ca_sub = ca_sub[ca_sub.index('C ='):].strip() + + # + # The last cert in the chain will either be a Root CA, or issued by a + # Root CA. If we find a matching subject in our previous -show_chain + # command we know there is a Root CA in the chain, if not we need to + # find the Root CA on the system. + # + ca_cert_index = len(results) - 2 + + for i, sub in enumerate(subjects): + if ca_sub == sub: + print("Root CA found in chain, index=%d" % i) + ca_cert_index = i + + # + # Now that we have this last cert, check if its a Root CA or not by + # checking if its hash matches the issuer hash. + # + with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(['openssl', 'x509', '-hash', + '-issuer_hash', '-noout', '-in', + '/tmp/intermediate%d.pem' % ca_cert_index], + stdout=subprocess.PIPE, stderr=devnull) + hashes, err = proc.communicate() + hashes = hashes.decode('utf-8').strip().split('\n') + + own = hashes[0] + issuer = hashes[1] + + if own == issuer: + # + # Since this is a Root CA, we should already have a copy on the + # system. Verify the hash links to a system cert. + # + path = find_root_ca_path(issuer) + if path is not None: + print("Verified Root CA exists: %s" % path) + else: + print("Root CA in chain could not be found on system") + return None + + cleanup(files) + return path + + print("Root CA not found in cert chain, looking on system") + + # + # The final cert in the chain was not a Root CA, check if we have a Root + # CA on the system matching the last certs issuer hash. + # + path = find_root_ca_path(issuer) + if path is None: + print("Could not find issuer %s" % issuer) + return None + + with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(['openssl', 'x509', '-hash', + '-issuer_hash', '-noout', '-in', path], + stdout=subprocess.PIPE, stderr=devnull) + + hashes, err = proc.communicate() + hashes = hashes.decode('utf-8').strip().split('\n') + + own = hashes[0] + issuer = hashes[1] + + if own == issuer: + path = find_root_ca_path(issuer) + if path is not None: + print("Verified Root CA exists: %s" % path) + else: + print("Root CA could not be found on system") + return None + + cleanup(files) + + return path + +iwd_dir='/var/lib/iwd' +cert_path = None + +description = ''' +Convert iOS mobileconfig file to IWD format. Currently only TTLS and PEAP are +supported. Inner methods supported are PAP, CHAP, MSCHAP, MSCHAPv2. +''' + +parser = argparse.ArgumentParser(description=description) +parser.add_argument('-i', '--input', nargs='?', required=True, + metavar='mobileconfig', help='iOS mobileconfig file') +parser.add_argument('-o', '--iwd-out', nargs='?', metavar='dir', + help='IWD configuration directory (default /var/lib/iwd)') +parser.add_argument('-u', '--user', action='store_true', + help='Store username in provisioning file') +parser.add_argument('-p', '--passwd', action='store_true', + help='Store password (plaintext) in provisioning file') +parser.add_argument('-v', '--verbose', action='store_true', + help='Enable verbose output') + +args = parser.parse_args() + +if args.iwd_out: + iwd_dir = args.iwd_out + +with open(os.devnull, 'w') as devnull: + proc = subprocess.Popen(['openssl', 'cms', '-in', args.input, '-inform', + 'der', '-verify', '-noverify'], + stdout=subprocess.PIPE, stderr=devnull) + +xml, err = proc.communicate() + +if args.verbose: + print(xml.decode("utf-8")) + +subprocess.call(['openssl', 'cms', '-in', args.input, '-inform', 'der', + '-outform', 'pem', '-noout', '-cmsout', '-certsout', + '/tmp/certchain.crt']) + +xml = xml.decode('utf-8') + +root = ElementTree.fromstring(xml) + +for i in range(len(root)): + for j in range(len(root[i])): + if root[i][j].text != "PayloadContent": + continue + if (root[i][j + 1].tag != "array"): + continue + + payload = root[i][j + 1] + nets = process_payload(payload) + +root_ca_path = find_root_ca('/tmp/certchain.crt') + +for n in nets: + write_network(n, root_ca_path)