feat(python/trezorctl): improving and refactoring firmware CLI commands

pull/1689/head
grdddj 3 years ago committed by matejcik
parent e67b3ab214
commit 6bbc9a78f3

@ -0,0 +1 @@
CLI: two new firmware commands - "trezorctl firmware download" and "trezorctl firmware verify"

@ -0,0 +1 @@
CLI: "trezorctl firmware-update" command changed to "trezorctl firmware update"

@ -7,6 +7,7 @@ list of credits:
alepop <https://github.com/alepop>
Jan 'matejcik' Matějek <jan.matejek@satoshilabs.com>
Jan Pochyla <jan.pochyla@satoshilabs.com>
Jiří Musil <https://github.com/grdddj>
Jochen Hoenicke <hoenicke@gmail.com>
Jonathan Cross <https://github.com/jonathancross>
Karel Bílek <karel.bilek@satoshilabs.com>

@ -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.
~~~~~~~~~~~~~~~~~

@ -14,13 +14,17 @@
# You should have received a copy of the License along with this library.
# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
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,
)

@ -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)
#

Loading…
Cancel
Save