diff --git a/python/.changelog.d/1258.added b/python/.changelog.d/1258.added new file mode 100644 index 000000000..9c30a1f7c --- /dev/null +++ b/python/.changelog.d/1258.added @@ -0,0 +1 @@ +CLI: two new firmware commands - "trezorctl firmware download" and "trezorctl firmware verify" diff --git a/python/.changelog.d/1258.changed b/python/.changelog.d/1258.changed new file mode 100644 index 000000000..456bb2184 --- /dev/null +++ b/python/.changelog.d/1258.changed @@ -0,0 +1 @@ +CLI: "trezorctl firmware-update" command changed to "trezorctl firmware update" diff --git a/python/AUTHORS b/python/AUTHORS index 7afc8c0a9..0dadfe765 100644 --- a/python/AUTHORS +++ b/python/AUTHORS @@ -7,6 +7,7 @@ list of credits: alepop Jan 'matejcik' Matějek Jan Pochyla +Jiří Musil Jochen Hoenicke Jonathan Cross Karel Bílek diff --git a/python/docs/OPTIONS.rst b/python/docs/OPTIONS.rst index 83de6c6c8..58b877fb6 100644 --- a/python/docs/OPTIONS.rst +++ b/python/docs/OPTIONS.rst @@ -46,7 +46,7 @@ on one page here. eos EOS commands. ethereum Ethereum commands. fido FIDO2, U2F and WebAuthN management commands. - firmware-update Upload new firmware to device. + firmware Firmware commands. get-features Retrieve device features and settings. get-session Get a session ID for subsequent commands. lisk Lisk commands. @@ -101,6 +101,7 @@ Bitcoin and Bitcoin-like coins commands. Commands: get-address Get address for specified path. + get-descriptor Get descriptor of given account. get-public-node Get public node of given path. sign-message Sign message using address of given path. sign-tx Sign transaction. @@ -186,7 +187,6 @@ Miscellaneous debug features. Commands: send-bytes Send raw bytes to Trezor. - show-text Show text on Trezor display. Device management commands - setup, recover seed, wipe, etc. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -276,6 +276,27 @@ FIDO2, U2F and WebAuthN management commands. counter Get or set the FIDO/U2F counter value. credentials Manage FIDO2 resident credentials. +Firmware commands. +~~~~~~~~~~~~~~~~~~ + +.. code:: + + trezorctl firmware --help + +.. code:: + + Usage: trezorctl firmware [OPTIONS] COMMAND [ARGS]... + + Firmware commands. + + Options: + --help Show this message and exit. + + Commands: + download Download and save the firmware image. + update Upload new firmware to device. + verify Verify the integrity of the firmware data stored in a file. + Lisk commands. ~~~~~~~~~~~~~~ @@ -376,15 +397,16 @@ Device settings. --help Show this message and exit. Commands: - auto-lock-delay Set auto-lock delay (in seconds). - display-rotation Set display rotation. - flags Set device flags. - homescreen Set new homescreen. - label Set new device label. - passphrase Enable, disable or configure passphrase protection. - pin Set, change or remove PIN. - safety-checks Set safety check level. - wipe-code Set or remove the wipe code. + auto-lock-delay Set auto-lock delay (in seconds). + display-rotation Set display rotation. + experimental-features Enable or disable experimental message types. + flags Set device flags. + homescreen Set new homescreen. + label Set new device label. + passphrase Enable, disable or configure passphrase protection. + pin Set, change or remove PIN. + safety-checks Set safety check level. + wipe-code Set or remove the wipe code. Stellar commands. ~~~~~~~~~~~~~~~~~ diff --git a/python/src/trezorlib/cli/firmware.py b/python/src/trezorlib/cli/firmware.py index f4df3fec6..a12d95247 100644 --- a/python/src/trezorlib/cli/firmware.py +++ b/python/src/trezorlib/cli/firmware.py @@ -14,13 +14,17 @@ # You should have received a copy of the License along with this library. # If not, see . +import os import sys +from typing import BinaryIO +from urllib.parse import urlparse import click import requests from .. import exceptions, firmware -from . import with_client +from ..client import TrezorClient +from . import TrezorConnection, with_client ALLOWED_FIRMWARE_FORMATS = { 1: (firmware.FirmwareFormat.TREZOR_ONE, firmware.FirmwareFormat.TREZOR_ONE_V2), @@ -28,12 +32,34 @@ ALLOWED_FIRMWARE_FORMATS = { } -def _print_version(version): +def _print_version(version: dict) -> None: vstr = "Firmware version {major}.{minor}.{patch} build {build}".format(**version) click.echo(vstr) -def validate_firmware(version, fw, expected_fingerprint=None): +def _is_bootloader_onev2(client: TrezorClient) -> bool: + """Check if bootloader is capable of installing the Trezor One v2 firmware directly. + + This is the case from bootloader version 1.8.0, and also holds for firmware version + 1.8.0 because that installs the appropriate bootloader. + """ + f = client.features + version = (f.major_version, f.minor_version, f.patch_version) + bootloader_onev2 = f.major_version == 1 and version >= (1, 8, 0) + return bootloader_onev2 + + +def _get_file_name_from_url(url: str) -> str: + """Parse the name of the file being downloaded from the specific url.""" + full_path = urlparse(url).path + return os.path.basename(full_path) + + +def print_firmware_version( + version: str, + fw: firmware.ParsedFirmware, +) -> None: + """Print out the firmware version and details.""" if version == firmware.FirmwareFormat.TREZOR_ONE: if fw.embedded_onev2: click.echo("Trezor One firmware with embedded v2 image (1.8.0 or later)") @@ -47,9 +73,20 @@ def validate_firmware(version, fw, expected_fingerprint=None): click.echo("Trezor T firmware image.") vendor = fw.vendor_header.text vendor_version = "{major}.{minor}".format(**fw.vendor_header.version) - click.echo("Vendor header from {}, version {}".format(vendor, vendor_version)) + click.echo(f"Vendor header from {vendor}, version {vendor_version}") _print_version(fw.image.header.version) + +def validate_signatures( + version: str, + fw: firmware.ParsedFirmware, +) -> None: + """Check the signatures on the firmware. + + Prints the validity status. + In case of Trezor One v1 prompts the user (as the signature is missing). + Exits if the validation fails. + """ try: firmware.validate(version, fw, allow_unsigned=False) click.echo("Signatures are valid.") @@ -68,24 +105,63 @@ def validate_firmware(version, fw, expected_fingerprint=None): click.echo("Firmware validation failed, aborting.") sys.exit(4) + +def validate_fingerprint( + version: str, fw: firmware.ParsedFirmware, expected_fingerprint: str = None +) -> None: + """Determine and validate the firmware fingerprint. + + Prints the fingerprint. + Exits if the validation fails. + """ fingerprint = firmware.digest(version, fw).hex() - click.echo("Firmware fingerprint: {}".format(fingerprint)) + click.echo(f"Firmware fingerprint: {fingerprint}") if version == firmware.FirmwareFormat.TREZOR_ONE and fw.embedded_onev2: fingerprint_onev2 = firmware.digest( firmware.FirmwareFormat.TREZOR_ONE_V2, fw.embedded_onev2 ).hex() - click.echo("Embedded v2 image fingerprint: {}".format(fingerprint_onev2)) + click.echo(f"Embedded v2 image fingerprint: {fingerprint_onev2}") if expected_fingerprint and fingerprint != expected_fingerprint: - click.echo("Expected fingerprint: {}".format(expected_fingerprint)) + click.echo(f"Expected fingerprint: {expected_fingerprint}") click.echo("Fingerprints do not match, aborting.") sys.exit(5) -def find_best_firmware_version( - bootloader_version, requested_version=None, beta=False, bitcoin_only=False -): - url = "https://data.trezor.io/firmware/{}/releases.json" - releases = requests.get(url.format(bootloader_version[0])).json() +def check_device_match( + version: str, + fw: firmware.ParsedFirmware, + bootloader_onev2: bool, + trezor_major_version: int, +) -> None: + """Validate if the device and firmware are compatible. + + Prints error message and exits if the validation fails. + """ + if ( + bootloader_onev2 + and version == firmware.FirmwareFormat.TREZOR_ONE + and not fw.embedded_onev2 + ): + click.echo("Firmware is too old for your device. Aborting.") + sys.exit(3) + elif not bootloader_onev2 and version == firmware.FirmwareFormat.TREZOR_ONE_V2: + click.echo("You need to upgrade to bootloader 1.8.0 first.") + sys.exit(3) + + if trezor_major_version not in ALLOWED_FIRMWARE_FORMATS: + click.echo("trezorctl doesn't know your device version. Aborting.") + sys.exit(3) + elif version not in ALLOWED_FIRMWARE_FORMATS[trezor_major_version]: + click.echo("Firmware does not match your device, aborting.") + sys.exit(3) + + +def get_all_firmware_releases( + bitcoin_only: bool, beta: bool, major_version: int +) -> list: + """Get sorted list of all releases suitable for inputted parameters""" + url = f"https://data.trezor.io/firmware/{major_version}/releases.json" + releases = requests.get(url).json() if not releases: raise click.ClickException("Failed to get list of releases") @@ -104,23 +180,56 @@ def find_best_firmware_version( releases.sort(key=lambda r: r["version"], reverse=True) + return releases + + +def find_best_firmware_version( + client: TrezorClient, + version: str = None, + beta: bool = False, + bitcoin_only: bool = False, +) -> tuple: + """Get the url from which to download the firmware and its expected fingerprint. + + When the version (X.Y.Z) is specified, checks for that specific release. + Otherwise takes the latest one. + + If the specified version is not found, prints the closest available version + (higher than the specified one, if existing). + """ + def version_str(version): return ".".join(map(str, version)) - want_version = requested_version + f = client.features + + releases = get_all_firmware_releases(bitcoin_only, beta, f.major_version) highest_version = releases[0]["version"] - if want_version is None: + if version: + want_version = [int(x) for x in version.split(".")] + if len(want_version) != 3: + click.echo("Please use the 'X.Y.Z' version format.") + if want_version[0] != f.major_version: + model = f.model or "1" + click.echo( + f"Warning: Trezor {model} firmware version should be " + f"{f.major_version}.X.Y (requested: {version})" + ) + else: want_version = highest_version - click.echo("Best available version: {}".format(version_str(want_version))) + click.echo(f"Best available version: {version_str(want_version)}") + # Identifying the release we will install + # It may happen that the different version will need to be installed first confirm_different_version = False while True: + # The want_version can be changed below, need to redefine it want_version_str = version_str(want_version) try: release = next(r for r in releases if r["version"] == want_version) except StopIteration: - click.echo("Version {} not found for your device.".format(want_version_str)) + click.echo(f"Version {want_version_str} not found for your device.") # look for versions starting with the lowest for release in reversed(releases): @@ -129,31 +238,35 @@ def find_best_firmware_version( # stop at first that is higher than the requested break # if there was no break, the newest is used - click.echo( - "Closest available version: {}".format(version_str(closest_version)) - ) + click.echo(f"Closest available version: {version_str(closest_version)}") if not beta and want_version > highest_version: click.echo("Hint: specify --beta to look for a beta release.") sys.exit(1) - if ( - "min_bootloader_version" in release - and release["min_bootloader_version"] > bootloader_version - ): - need_version_str = version_str(release["min_firmware_version"]) + # It can be impossible to update from a very old version directly + # to the newer one, in that case update to the minimal + # compatible version first + # Choosing the version key to compare based on (not) being in BL mode + client_version = [f.major_version, f.minor_version, f.patch_version] + if f.bootloader_mode: + key_to_compare = "min_bootloader_version" + else: + key_to_compare = "min_firmware_version" + + if key_to_compare in release and release[key_to_compare] > client_version: + need_version = release["min_firmware_version"] + need_version_str = version_str(need_version) click.echo( - "Version {} is required before upgrading to {}.".format( - need_version_str, want_version_str - ) + f"Version {need_version_str} is required before upgrading to {want_version_str}." ) - want_version = release["min_firmware_version"] + want_version = need_version confirm_different_version = True else: break if confirm_different_version: - installing_different = "Installing version {} instead.".format(want_version_str) - if requested_version is None: + installing_different = f"Installing version {want_version_str} instead." + if version is None: click.echo(installing_different) else: ok = click.confirm(installing_different + " Continue?", default=True) @@ -166,39 +279,212 @@ def find_best_firmware_version( else: url = release["url"] fingerprint = release["fingerprint"] - if not url.startswith("data/"): - click.echo("Unsupported URL found: {}".format(url)) + + url_prefix = "data/" + if not url.startswith(url_prefix): + click.echo(f"Unsupported URL found: {url}") sys.exit(1) - url = "https://data.trezor.io/" + url[5:] + final_url = "https://data.trezor.io/" + url[len(url_prefix) :] + + return final_url, fingerprint + + +def download_firmware_data(url: str) -> bytes: + try: + click.echo(f"Downloading from {url}") + r = requests.get(url) + r.raise_for_status() + return r.content + except requests.exceptions.HTTPError as err: + click.echo(f"Error downloading file: {err}") + sys.exit(3) + + +def validate_firmware( + firmware_data: bytes, + fingerprint: str = None, + bootloader_onev2: bool = None, + trezor_major_version: int = None, +) -> None: + """Validate the firmware through multiple tests. + + - parsing it properly + - containing valid signatures and fingerprint (when chosen) + - being compatible with the device (when chosen) + """ + try: + version, fw = firmware.parse(firmware_data) + except Exception as e: + click.echo(e) + sys.exit(2) + + print_firmware_version(version, fw) + validate_signatures(version, fw) + validate_fingerprint(version, fw, fingerprint) + + if bootloader_onev2 is not None and trezor_major_version is not None: + check_device_match( + version=version, + fw=fw, + bootloader_onev2=bootloader_onev2, + trezor_major_version=trezor_major_version, + ) + click.echo("Firmware is appropriate for your device.") + + +def extract_embedded_fw( + firmware_data: bytes, + bootloader_onev2: bool, +) -> bytes: + """Modify the firmware data for sending into Trezor, if necessary.""" + # special handling for embedded-OneV2 format: + # for bootloader < 1.8, keep the embedding + # for bootloader 1.8.0 and up, strip the old OneV1 header + if ( + bootloader_onev2 + and firmware_data[:4] == b"TRZR" + and firmware_data[256 : 256 + 4] == b"TRZF" + ): + click.echo("Extracting embedded firmware image.") + return firmware_data[256:] + + return firmware_data + + +def upload_firmware_into_device( + client: TrezorClient, + firmware_data: bytes, +) -> None: + """Perform the final act of loading the firmware into Trezor.""" + f = client.features + try: + if f.major_version == 1 and f.firmware_present is not False: + # Trezor One does not send ButtonRequest + click.echo("Please confirm the action on your Trezor device") + firmware.update(client, firmware_data) + except exceptions.Cancelled: + click.echo("Update aborted on device.") + except exceptions.TrezorException as e: + click.echo(f"Update failed: {e}") + sys.exit(3) + + +@click.group(name="firmware") +def cli(): + """Firmware commands.""" + + +@cli.command() +# fmt: off +@click.argument("filename", type=click.File("rb")) +@click.option("-c", "--check-device", is_flag=True, help="Validate device compatibility") +@click.option("--fingerprint", help="Expected firmware fingerprint in hex") +@click.pass_obj +# fmt: on +def verify( + obj: TrezorConnection, + filename: BinaryIO, + check_device: bool, + fingerprint: str, +) -> None: + """Verify the integrity of the firmware data stored in a file. + + By default the device is not checked and does not need to be connected. + Its validation must be specified. + + In case of validation failure exits with the appropriate exit code. + """ + # Deciding if to take the device into account + if check_device: + with obj.client_context() as client: + bootloader_onev2 = _is_bootloader_onev2(client) + trezor_major_version = client.features.major_version + else: + bootloader_onev2 = None + trezor_major_version = None - return url, fingerprint + firmware_data = filename.read() + validate_firmware( + firmware_data=firmware_data, + fingerprint=fingerprint, + bootloader_onev2=bootloader_onev2, + trezor_major_version=trezor_major_version, + ) -@click.command() +@cli.command() # fmt: off -@click.option("-f", "--filename", type=click.File("rb")) -@click.option("-u", "--url") -@click.option("-v", "--version") +@click.option("-o", "--output", type=click.File("wb"), help="Output file to save firmware data to") +@click.option("-v", "--version", help="Which version to download") +@click.option("-s", "--skip-check", is_flag=True, help="Do not validate firmware integrity") +@click.option("--beta", is_flag=True, help="Use firmware from BETA channel") +@click.option("--bitcoin-only", is_flag=True, help="Use bitcoin-only firmware (if possible)") +@click.option("--fingerprint", help="Expected firmware fingerprint in hex") +# fmt: on +@with_client +def download( + client: TrezorClient, + output: BinaryIO, + version: str, + skip_check: bool, + fingerprint: str, + beta: bool, + bitcoin_only: bool, +) -> None: + """Download and save the firmware image. + + Validation is done by default, can be omitted by "-s" or "--skip-check". + When fingerprint or output file are not set, take them from SL servers. + """ + url, fp = find_best_firmware_version( + client=client, version=version, beta=beta, bitcoin_only=bitcoin_only + ) + + firmware_data = download_firmware_data(url) + + if not fingerprint: + fingerprint = fp + + if not skip_check: + validate_firmware( + firmware_data=firmware_data, + fingerprint=fingerprint, + bootloader_onev2=_is_bootloader_onev2(client), + trezor_major_version=client.features.major_version, + ) + + if not output: + output = open(_get_file_name_from_url(url), "wb") + output.write(firmware_data) + output.close() + click.echo(f"Firmware saved under {output.name}.") + + +@cli.command() +# fmt: off +@click.option("-f", "--filename", type=click.File("rb"), help="File containing firmware data") +@click.option("-u", "--url", help="Where to get the firmware from - full link") +@click.option("-v", "--version", help="Which version to download") @click.option("-s", "--skip-check", is_flag=True, help="Do not validate firmware integrity") @click.option("-n", "--dry-run", is_flag=True, help="Perform all steps but do not actually upload the firmware") @click.option("--beta", is_flag=True, help="Use firmware from BETA channel") @click.option("--bitcoin-only", is_flag=True, help="Use bitcoin-only firmware (if possible)") -@click.option("--raw", is_flag=True, help="Push raw data to Trezor") +@click.option("--raw", is_flag=True, help="Push raw firmware data to Trezor") @click.option("--fingerprint", help="Expected firmware fingerprint in hex") # fmt: on @with_client -def firmware_update( - client, - filename, - url, - version, - skip_check, - fingerprint, - raw, - dry_run, - beta, - bitcoin_only, -): +def update( + client: TrezorClient, + filename: BinaryIO, + url: str, + version: str, + skip_check: bool, + fingerprint: str, + raw: bool, + dry_run: bool, + beta: bool, + bitcoin_only: bool, +) -> None: """Upload new firmware to device. Device must be in bootloader mode. @@ -220,86 +506,36 @@ def firmware_update( click.echo("Please switch your device to bootloader mode.") sys.exit(1) - # bootloader for T1 does not export 'model', so we rely on major_version - f = client.features - bootloader_version = (f.major_version, f.minor_version, f.patch_version) - bootloader_onev2 = f.major_version == 1 and bootloader_version >= (1, 8, 0) - model = client.features.model or "1" - if filename: - data = filename.read() + firmware_data = filename.read() else: if not url: - if version: - version_list = [int(x) for x in version.split(".")] - if version_list[0] != bootloader_version[0]: - click.echo( - "Warning: Trezor {} firmware version should be {}.X.Y (requested: {})".format( - model, bootloader_version[0], version - ) - ) - else: - version_list = None url, fp = find_best_firmware_version( - list(bootloader_version), version_list, beta, bitcoin_only + client=client, version=version, beta=beta, bitcoin_only=bitcoin_only ) if not fingerprint: fingerprint = fp - try: - click.echo("Downloading from {}".format(url)) - r = requests.get(url) - r.raise_for_status() - except requests.exceptions.HTTPError as err: - click.echo("Error downloading file: {}".format(err)) - sys.exit(3) - - data = r.content + firmware_data = download_firmware_data(url) if not raw and not skip_check: - try: - version, fw = firmware.parse(data) - except Exception as e: - click.echo(e) - sys.exit(2) - - validate_firmware(version, fw, fingerprint) - if ( - bootloader_onev2 - and version == firmware.FirmwareFormat.TREZOR_ONE - and not fw.embedded_onev2 - ): - click.echo("Firmware is too old for your device. Aborting.") - sys.exit(3) - elif not bootloader_onev2 and version == firmware.FirmwareFormat.TREZOR_ONE_V2: - click.echo("You need to upgrade to bootloader 1.8.0 first.") - sys.exit(3) - - if f.major_version not in ALLOWED_FIRMWARE_FORMATS: - click.echo("trezorctl doesn't know your device version. Aborting.") - sys.exit(3) - elif version not in ALLOWED_FIRMWARE_FORMATS[f.major_version]: - click.echo("Firmware does not match your device, aborting.") - sys.exit(3) + validate_firmware( + firmware_data=firmware_data, + fingerprint=fingerprint, + bootloader_onev2=_is_bootloader_onev2(client), + trezor_major_version=client.features.major_version, + ) if not raw: - # special handling for embedded-OneV2 format: - # for bootloader < 1.8, keep the embedding - # for bootloader 1.8.0 and up, strip the old OneV1 header - if bootloader_onev2 and data[:4] == b"TRZR" and data[256 : 256 + 4] == b"TRZF": - click.echo("Extracting embedded firmware image.") - data = data[256:] + firmware_data = extract_embedded_fw( + firmware_data=firmware_data, + bootloader_onev2=_is_bootloader_onev2(client), + ) if dry_run: click.echo("Dry run. Not uploading firmware to device.") else: - try: - if f.major_version == 1 and f.firmware_present is not False: - # Trezor One does not send ButtonRequest - click.echo("Please confirm the action on your Trezor device") - return firmware.update(client, data) - except exceptions.Cancelled: - click.echo("Update aborted on device.") - except exceptions.TrezorException as e: - click.echo("Update failed: {}".format(e)) - sys.exit(3) + upload_firmware_into_device( + client=client, + firmware_data=firmware_data, + ) diff --git a/python/src/trezorlib/cli/trezorctl.py b/python/src/trezorlib/cli/trezorctl.py index ea2b8a62c..e15548552 100755 --- a/python/src/trezorlib/cli/trezorctl.py +++ b/python/src/trezorlib/cli/trezorctl.py @@ -75,10 +75,12 @@ COMMAND_ALIASES = { "xrp": ripple.cli, "xlm": stellar.cli, "xtz": tezos.cli, - # firmware-update aliases: - "update-firmware": firmware.firmware_update, - "upgrade-firmware": firmware.firmware_update, - "firmware-upgrade": firmware.firmware_update, + # firmware aliases: + "fw": firmware.cli, + "update-firmware": firmware.update, + "upgrade-firmware": firmware.update, + "firmware-upgrade": firmware.update, + "firmware-update": firmware.update, } @@ -339,7 +341,7 @@ cli.add_command(settings.cli) cli.add_command(stellar.cli) cli.add_command(tezos.cli) -cli.add_command(firmware.firmware_update) +cli.add_command(firmware.cli) cli.add_command(debug.cli) #