diff --git a/tools/updater/precursorupdater/precursorupdater.py b/tools/updater/precursorupdater/precursorupdater.py index 486b2cae9..f11fb4e3a 100755 --- a/tools/updater/precursorupdater/precursorupdater.py +++ b/tools/updater/precursorupdater/precursorupdater.py @@ -12,6 +12,13 @@ try: except ImportError: from precursorusb import PrecursorUsb +from bip_utils import ( + Bip39MnemonicValidator, Bip39MnemonicDecoder +) +from Crypto.Cipher import AES +from Crypto.Hash import SHA256 +from Crypto.Random import get_random_bytes + QR_CODE = """ ██████████████ ██ ████ ██████ ██ ██████████████ @@ -46,6 +53,163 @@ QR_CODE = """ """ +def bitflip(data_block, bitwidth=32): + if bitwidth == 0: + return data_block + bytewidth = bitwidth // 8 + bitswapped = bytearray() + i = 0 + while i < len(data_block): + data = int.from_bytes(data_block[i:i+bytewidth], byteorder='big', signed=False) + b = '{:0{width}b}'.format(data, width=bitwidth) + bitswapped.extend(int(b[::-1], 2).to_bytes(bytewidth, byteorder='big')) + i = i + bytewidth + return bytes(bitswapped) + +# assumes a, b are the same length eh? +def xor_bytes(a, b): + i = 0 + y = bytearray() + while i < len(a): + y.extend((a[i] ^ b[i]).to_bytes(1, byteorder='little')) + i = i + 1 + + return bytes(y) + +def try_key_to_bytes(input): + if len(input.split(' ')) == 24: # 24 words is BIP-39 + # Get if a mnemonic is valid with automatic language detection, return bool + assert(Bip39MnemonicValidator().IsValid(input)) + # Like before with automatic language detection + key_bytes = Bip39MnemonicDecoder().Decode(input) + else: + key_bytes = int(input, 16).to_bytes(32, byteorder='big') + return key_bytes + +# binfile should be the input SoC file, already read in as bytes() +# returns the encrypted version of binfile +def encrypt_to_efuse(binfile, key): + print("Encrypting gateware to target-specific key...") + # extract the keys + key_bytes = bytes([0] * 32) + new_key = try_key_to_bytes(key) + new_hmac = get_random_bytes(32) + new_iv = get_random_bytes(16) + + # search for structure + # 0x3001_6004 -> specifies the CBC key + # 4 words of CBC IV + # 0x3003_4001 -> ciphertext len + # 1 word of ciphertext len + # then ciphertext + + position = 0 + iv_pos = 0 + while position < len(binfile): + cwd = int.from_bytes(binfile[position:position+4], 'big') + if cwd == 0x3001_6004: + iv_pos = position+4 + if cwd == 0x3003_4001: + break + position = position + 1 + + position = position + 4 + + ciphertext_len = 4* int.from_bytes(binfile[position:position+4], 'big') + position = position + 4 + + active_area = binfile[position : position+ciphertext_len] + postamble = binfile[position+ciphertext_len:] + + iv_bytes = bitflip(binfile[iv_pos : iv_pos+0x10]) # note that the IV is embedded in the file + + cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) + plain_bitstream = cipher.decrypt(bitflip(active_area)) + + # now construct the output file and its hashes + plaintext = bytearray() + f = bytearray() + + # fixed header that sets 66MHz config speed, x1, 1.8V, eFuse target + device_header = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xaa, 0x99, 0x55, 0x66, 0x20, 0x00, 0x00, 0x00, 0x30, 0x03, 0xe0, 0x01, 0x00, 0x00, 0x00, 0x0b, + 0x30, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x12, 0x20, 0x00, 0x00, 0x00, 0x30, 0x00, 0xc0, 0x01, + 0x80, 0x00, 0x00, 0x40, 0x30, 0x00, 0xa0, 0x01, 0x80, 0x00, 0x00, 0x40, 0x30, 0x01, 0xc0, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x30, 0x01, 0x60, 0x04, + ] + + for item in device_header: # add the cleartext header + f.extend(bytes([item])) + + f.extend(bitflip(new_iv)) # insert the IV + + ciphertext_header = [ + 0x30, 0x03, 0x40, 0x01, 0x00, 0x08, 0x5b, 0x98, + ] + for item in ciphertext_header: # add the cleartext length-of-ciphertext field before the ciphertext + f.extend(bytes([item])) + + # generate the header and footer hash keys. + header = int(0x6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C6C).to_bytes(32, byteorder='big') + keyed_header = xor_bytes(header, new_hmac) + footer = int(0x3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A3A).to_bytes(32, byteorder='big') + keyed_footer = xor_bytes(footer, new_hmac) + + # add the header + plaintext.extend(keyed_header) + plaintext.extend(header) + + # insert the bitstream plaintext, skipping the header and the trailing HMAC. + plaintext.extend(plain_bitstream[64:-160]) + + # compute first HMAC of stream with new HMAC key + h1 = SHA256.new() + k = 0 + while k < len(plaintext) - 320: # HMAC does /not/ cover the whole file, it stops 320 bytes short of the end + h1.update(bitflip(plaintext[k:k+16], 32)) + k = k + 16 + h1_digest = h1.digest() + + # add the footer + plaintext.extend(keyed_footer) + plaintext.extend(footer) + plaintext.extend(bytes(32)) # empty spot where hash #1 would be stored + hash_pad = [ # sha-256 padding for the zero'd hash #1, which is in the bitstream and seems necessary for verification + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, + ] + plaintext.extend(hash_pad) + + # compute the hash of the hash, presumably to prevent length extension attacks? + h2 = SHA256.new() + h2.update(bitflip(keyed_footer)) + h2.update(bitflip(footer)) + h2.update(h1_digest) + h2_digest = h2.digest() + + # commit the final HMAC to the bitstream plaintext + plaintext.extend(bitflip(h2_digest)) + + # encrypt the bitstream + newcipher = AES.new(new_key, AES.MODE_CBC, new_iv) + + # finally generate the ciphertext block, which encapsulates the HMACs + ciphertext = newcipher.encrypt(bytes(plaintext)) + + # add ciphertext to the bitstream + f.extend(bitflip(ciphertext)) + + # add the cleartext postamble to the bitstream. These are a series of NOP commands + all of the csr.csv data & signatures + f.extend(postamble) + print("Encryption success! {} bytes generated.".format(len(f))) + assert len(f) == 2621440, "Encryption length is incorrect; aborting!" + + return f + def get_with_progress(url, name='Downloading'): r = requests.get(url, stream=True) total_length = int(r.headers.get('content-length')) @@ -148,6 +312,9 @@ def main(): parser.add_argument( "--dry-run", required=False, help="Don't actually burn anything, but print what would happen if we did", action="store_true" ) + parser.add_argument( + "--key", help="Backup key in hex or BIP-39 format. Used to factory-reset eFused devices. Specify BIP-39 phrase within double-quotes.", type=str + ) args = parser.parse_args() @@ -210,6 +377,7 @@ def main(): exit(0) if args.factory_reset: + print("\nWARNING: if a backup key is set, the correct key MUST be specified with `--key`, or else the device will be bricked.") if False == single_yes_or_no_question("This will permanently erase user data on the device. Proceed? "): print("Abort by user request.") exit(0) @@ -271,6 +439,9 @@ def main(): pc_usb.erase_region(work[2], work[3]) break else: + if args.factory_reset and args.key is not None and work[1] == "Overwriting boot gateware": + # re-encrypt the gateware if we're doing a factory reset and a key was specified + work[3] = encrypt_to_efuse(work[3], args.key) if args.dry_run: print("DRYRUN: Would write at 0x{:x}".format(work[2])) else: diff --git a/tools/updater/setup.py b/tools/updater/setup.py index 5b6805005..9470d3c10 100644 --- a/tools/updater/setup.py +++ b/tools/updater/setup.py @@ -7,7 +7,7 @@ with open('README.md') as f: setup( name="precursorupdater", - version="0.0.9", + version="0.1.0", author="bunnie", description="Precursor USB Updater", long_description=long_description, @@ -33,6 +33,8 @@ setup( "requests >= 2", "pyusb >= 1", "progressbar2 >= 3", + "bip-utils >= 2.5", + "pycryptodome >= 3", ], entry_points=""" [console_scripts]