diff --git a/.travis.yml b/.travis.yml index ac7da72..0a270ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ language: python python: - 3.5 - 3.6 + - 3.7-dev install: - pip install . @@ -15,7 +16,18 @@ script: iv = $(head -c16 /dev/urandom | base64 -w0) signing_key = $(openssl genrsa 1024 | sed 's/^/ /') EOF - - echo '' >test.xml + - | + cat <test.xml + + + + + 0C78E1B906C54D3A8F264CE5C4F0B9DF + + + + + EOF - python codeplug.py build test.xml - python codeplug.py decode test.xml.ctb - diff -u test.xml test.xml.ctb.xml diff --git a/codeplug.py b/codeplug.py index b553417..4546023 100644 --- a/codeplug.py +++ b/codeplug.py @@ -15,7 +15,57 @@ from cryptography.hazmat.primitives.hashes import Hash, SHA1 from cryptography.hazmat.primitives.padding import PKCS7 from cryptography.hazmat.primitives.serialization import load_pem_private_key -from OpenSSL.crypto import load_pkcs12 +from cryptography.hazmat.primitives.serialization.pkcs12 import load_pkcs12 + + +_CP1252_BESTFIT = { + 128: 8364, + 130: 8218, + 131: 402, + 132: 8222, + 133: 8230, + 134: 8224, + 135: 8225, + 136: 710, + 137: 8240, + 138: 352, + 139: 8249, + 140: 338, + 142: 381, + 145: 8216, + 146: 8217, + 147: 8220, + 148: 8221, + 149: 8226, + 150: 8211, + 151: 8212, + 152: 732, + 153: 8482, + 154: 353, + 155: 8250, + 156: 339, + 158: 382, + 159: 376, +} + + +_CP1252_BESTFIT_INVERSE = {c: u for u, c in _CP1252_BESTFIT.items()} + + +_UUID_TO_ARCHIVE_TYPE = { + 'AF5DAB63F4FC4926BB9000A6F18AF3DC': 'BAHAMA', + '0571AFE244664F999A96B020E82DC69C': 'GEMSTONE', + '0C78E1B906C54D3A8F264CE5C4F0B9DF': 'GEMSTONE', + 'C52A3D4953FE469D8E11F05B143E8C56': 'GEMSTONE', + 'EBCCE9BF33B14896B5C2E7E3AA19AF0F': 'GEMSTONE', + '9F6C2442C375421981A8987115FC9ADE': 'MALTA', + 'D105ADD323864E539B513A65076458D3': 'MATRIX', + '0C0D6EE58204FBDEBB8860C631AB465A': 'PARADISE_LIGHT', + '1EC82E1A4AE2B4F1A8AC27E8039CB7E4': 'PARADISE_LIGHT', + 'C4FC39D8DEF24B779D1CB719AF26A269': 'PARADISE_LIGHT', + '06CE7B7163C0456A845A6E13421F0AE4': 'PHOENIX', + '106F58B631044D63B41F0C0D7720758D': 'REUNION', +} def _int_to_bytes(number): @@ -35,12 +85,90 @@ def _key_to_xml(key, root): etree.SubElement(node, 'Exponent').text = base64.b64encode(_int_to_bytes(numbers.e)) +def _encode_xml_chars(data: bytes) -> bytes: + """Encode control characters""" + result = [] + for c in data: + if c < 0x20 and c not in [0x09, 0x0a, 0x0d]: + result += '&#x{:X};'.format(c).encode() + else: + result.append(c) + return bytes(result) + + +def _decode_xml_chars(data: bytes) -> bytes: + """Decode control characters""" + for c in range(0, 0x20): + if c in [0x09, 0x0a, 0x0d]: + continue + data = data.replace('&#x{:X};'.format(c).encode(), bytes([c])) + return data + + +def _encode_ascii_binary(data: bytes) -> str: + """Map non-printable characters to a Unicode Private Use Area""" + result = [] + for c in data: + if (c >= 0x20 and c < 0x7f) or c == 0x0a: + result.append(c) + else: + result += chr(0xe000 | c).encode('utf-8') + return bytes(result).decode() + + +def _decode_ascii_binary(data: str) -> bytes: + """Map non-printable characters back from the Unicode Private Use Area""" + result = [] + for c in data: + p = ord(c) + if p >= 0xe000 and p <= 0xe0ff: + result.append(p & 0xff) + else: + result.append(p) + return bytes(result) + + +def _decode_xml_privateuse(data: bytes) -> bytes: + """Decode Unicode characters of the Unicode Private Use Area""" + result = [] + for c in data: + p = ord(c) + if p >= 0xe000 and p <= 0xe0ff: + result.append(p & 0xff) + else: + result.append(p) + return bytes(result) + + +def _encode_cp1252_bestfit(data: bytes) -> bytes: + """Map CP-1252 codepoints to Unicode""" + result = b'' + for c in data: + result += chr(_CP1252_BESTFIT.get(c, c)).encode('utf-8') + return result + + +def _decode_cp1252_bestfit(data: bytes) -> bytes: + """Interpret bytes as Unicode codepoints and map them back to CP-1252""" + result = [] + for c in data: + p = ord(c) + result.append(_CP1252_BESTFIT_INVERSE.get(p, p)) + return bytes(result) + + def decode(data, key, iv): backend = default_backend() - doc = etree.fromstring(data) - node = doc.xpath('/ARCHIVE/RADIO')[0] - encrypted = base64.b64decode(node.text) + doc = etree.fromstring(_encode_ascii_binary(_decode_xml_chars(data.strip()))) + archive_type = doc.get('TYPE') + archive_content = doc.xpath('RADIO')[0].text + if archive_type == 'GEMSTONE': + encrypted = base64.b64decode(archive_content) + elif archive_type == 'MATRIX': + encrypted = _decode_cp1252_bestfit(_decode_ascii_binary(archive_content).decode()) + else: + raise Exception('Unsupported archive type: {}'.format(archive_type)) decryptor = Cipher(AES(key), CBC(iv), backend=backend).decryptor() compressed = decryptor.update(encrypted) + decryptor.finalize() @@ -57,12 +185,10 @@ def decode(data, key, iv): key = _key_from_xml(signature, backend) key.verify(digest, payload.decode('utf-8').encode('utf-16-le'), PKCS1v15(), SHA1()) - return payload + return archive_type, payload -def build(payload, signing_key, key, iv, backend): - backend = signing_key._backend - +def build(archive_type, payload, signing_key, key, iv, backend): signature = signing_key.sign(payload.decode('utf-8').encode('utf-16-le'), PKCS1v15(), SHA1()) sign_doc = etree.Element('SIGNATURE') @@ -82,13 +208,20 @@ def build(payload, signing_key, key, iv, backend): encrypted = encryptor.update(padded) + encryptor.finalize() doc = etree.Element('ARCHIVE') - doc.set('TYPE', 'GEMSTONE') + doc.set('TYPE', archive_type) node = etree.SubElement(doc, 'RADIO') node.set('VERSION', '1') - node.set('ENCODING', 'Base64') - node.text = base64.b64encode(encrypted) + if archive_type == 'GEMSTONE': + node.set('ENCODING', 'Base64') + node.text = base64.b64encode(encrypted) + elif archive_type == 'MATRIX': + node.text = _encode_ascii_binary(_encode_cp1252_bestfit(encrypted)) + else: + raise Exception('Unsupported archive type: {}'.format(archive_type)) - return etree.tostring(doc) + xml = etree.tostring(doc, encoding='utf-8') + + return _encode_xml_chars(_decode_ascii_binary(xml.decode())) def _read_config(filename): @@ -116,36 +249,47 @@ def _decode_cmd(args): with open(args.file, 'rb') as f: data = f.read() - result = decode(data, base64.b64decode(config['key']), base64.b64decode(config['iv'])) + archive_type, result = decode(data, base64.b64decode(config['key']), base64.b64decode(config['iv'])) xml = etree.fromstring(result) - with open(args.output or args.file + '.xml', 'wb') as f: + output_path = args.output or args.file + '.xml' + with open(output_path, 'wb') as f: f.write(etree.tostring(xml, pretty_print=True)) + print('Decoded {} archive to {}'.format(archive_type, output_path)) + def _build_cmd(args): config = _read_config(args.config) backend = default_backend() - payload = etree.tostring(etree.parse(args.file)) + doc = etree.parse(args.file) + + uuid = doc.xpath('//CS_FWID')[0].text + archive_type = _UUID_TO_ARCHIVE_TYPE[uuid] + + payload = etree.tostring(doc, encoding='utf-8') if 'signing_password' in config: - signing_key = load_pkcs12(base64.b64decode(config['signing_key']), base64.b64decode(config['signing_password'])).get_privatekey().to_cryptography_key() + signing_key = load_pkcs12(base64.b64decode(config['signing_key']),base64.b64decode(config['signing_password'])).key else: signing_key = load_pem_private_key(config['signing_key'].encode('ascii'), password=None, backend=backend) - result = build(payload, signing_key, base64.b64decode(config['key']), base64.b64decode(config['iv']), backend) + result = build(archive_type, payload, signing_key, base64.b64decode(config['key']), base64.b64decode(config['iv']), backend) - with open(args.output or args.file + '.ctb', 'wb') as f: + output_path = args.output or args.file + '.ctb' + with open(output_path, 'wb') as f: f.write(result) + print('Built {} archive in {}'.format(archive_type, output_path)) + def main(): parent_parser = argparse.ArgumentParser(add_help=False) - parent_parser.add_argument('-c', dest='config') - parent_parser.add_argument('-o', dest='output') + parent_parser.add_argument('-c', help='load configuration from the specified file', dest='config') + parent_parser.add_argument('-o', help='output result to the specified file', dest='output') parent_parser.add_argument('file') parser = argparse.ArgumentParser() diff --git a/setup.py b/setup.py index c6176ff..2d08e78 100644 --- a/setup.py +++ b/setup.py @@ -26,9 +26,9 @@ 'Topic :: Communications :: Ham Radio', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ], keywords='motorola mototrbo cps ctb codeplug dmr',