# Copyright (c) 2018 Foundries.io # # SPDX-License-Identifier: Apache-2.0 import abc import argparse import os import pathlib import pickle import platform import shutil import subprocess import sys from west import manifest from west.commands import Verbosity from west.util import quote_sh_list from build_helpers import find_build_dir, is_zephyr_build, \ FIND_BUILD_DIR_DESCRIPTION from runners.core import BuildConfiguration from zcmake import CMakeCache from zephyr_ext_common import Forceable, ZEPHYR_SCRIPTS # This is needed to load edt.pickle files. sys.path.insert(0, str(ZEPHYR_SCRIPTS / 'dts' / 'python-devicetree' / 'src')) SIGN_DESCRIPTION = '''\ This command automates some of the drudgery of creating signed Zephyr binaries for chain-loading by a bootloader. In the simplest usage, run this from your build directory: west sign -t your_tool -- ARGS_FOR_YOUR_TOOL The "ARGS_FOR_YOUR_TOOL" value can be any additional arguments you want to pass to the tool, such as the location of a signing key etc. Depending on which sort of ARGS_FOR_YOUR_TOOLS you use, the `--` separator/sentinel may not always be required. To avoid ambiguity and having to find and understand POSIX 12.2 Guideline 10, always use `--`. See tool-specific help below for details.''' SIGN_EPILOG = '''\ imgtool ------- To build a signed binary you can load with MCUboot using imgtool, run this from your build directory: west sign -t imgtool -- --key YOUR_SIGNING_KEY.pem For this to work, either imgtool must be installed (e.g. using pip3), or you must pass the path to imgtool.py using the -p option. Assuming your binary was properly built for processing and handling by imgtool, this creates zephyr.signed.bin and zephyr.signed.hex files which are ready for use by your bootloader. The version number, image header size, alignment, and slot sizes are determined from the build directory using .config and the device tree. As shown above, extra arguments after a '--' are passed to imgtool directly. rimage ------ To create a signed binary with the rimage tool, run this from your build directory: west sign -t rimage -- -k YOUR_SIGNING_KEY.pem For this to work, either rimage must be installed or you must pass the path to rimage using the -p option. You can also pass additional arguments to rimage thanks to [sign] and [rimage] sections in your west config file(s); this is especially useful when invoking west sign _indirectly_ through CMake/ninja. See how at https://docs.zephyrproject.org/latest/develop/west/sign.html ''' class ToggleAction(argparse.Action): def __call__(self, parser, args, ignored, option): setattr(args, self.dest, not option.startswith('--no-')) class Sign(Forceable): def __init__(self): super(Sign, self).__init__( 'sign', # Keep this in sync with the string in west-commands.yml. 'sign a Zephyr binary for bootloader chain-loading', SIGN_DESCRIPTION, accepts_unknown_args=False) def do_add_parser(self, parser_adder): parser = parser_adder.add_parser( self.name, epilog=SIGN_EPILOG, help=self.help, formatter_class=argparse.RawDescriptionHelpFormatter, description=self.description) parser.add_argument('-d', '--build-dir', help=FIND_BUILD_DIR_DESCRIPTION) parser.add_argument('-q', '--quiet', action='store_true', help='suppress non-error output') self.add_force_arg(parser) # general options group = parser.add_argument_group('tool control options') group.add_argument('-t', '--tool', choices=['imgtool', 'rimage'], help='''image signing tool name; imgtool and rimage are currently supported (imgtool is deprecated)''') group.add_argument('-p', '--tool-path', default=None, help='''path to the tool itself, if needed''') group.add_argument('-D', '--tool-data', default=None, help='''path to a tool-specific data/configuration directory, if needed''') group.add_argument('--if-tool-available', action='store_true', help='''Do not fail if the rimage tool is not found or the rimage signing schema (rimage "target") is not defined in board.cmake.''') group.add_argument('tool_args', nargs='*', metavar='tool_opt', help='extra option(s) to pass to the signing tool') # bin file options group = parser.add_argument_group('binary (.bin) file options') group.add_argument('--bin', '--no-bin', dest='gen_bin', nargs=0, action=ToggleAction, help='''produce a signed .bin file? (default: yes, if supported and unsigned bin exists)''') group.add_argument('-B', '--sbin', metavar='BIN', help='''signed .bin file name (default: zephyr.signed.bin in the build directory, next to zephyr.bin)''') # hex file options group = parser.add_argument_group('Intel HEX (.hex) file options') group.add_argument('--hex', '--no-hex', dest='gen_hex', nargs=0, action=ToggleAction, help='''produce a signed .hex file? (default: yes, if supported and unsigned hex exists)''') group.add_argument('-H', '--shex', metavar='HEX', help='''signed .hex file name (default: zephyr.signed.hex in the build directory, next to zephyr.hex)''') return parser def do_run(self, args, ignored): self.args = args # for check_force # Find the build directory and parse .config and DT. build_dir = find_build_dir(args.build_dir) self.check_force(os.path.isdir(build_dir), 'no such build directory {}'.format(build_dir)) self.check_force(is_zephyr_build(build_dir), "build directory {} doesn't look like a Zephyr build " 'directory'.format(build_dir)) build_conf = BuildConfiguration(build_dir) if not args.tool: args.tool = self.config_get('sign.tool') # Decide on output formats. formats = [] bin_exists = build_conf.getboolean('CONFIG_BUILD_OUTPUT_BIN') if args.gen_bin: self.check_force(bin_exists, '--bin given but CONFIG_BUILD_OUTPUT_BIN not set ' "in build directory's ({}) .config". format(build_dir)) formats.append('bin') elif args.gen_bin is None and bin_exists: formats.append('bin') hex_exists = build_conf.getboolean('CONFIG_BUILD_OUTPUT_HEX') if args.gen_hex: self.check_force(hex_exists, '--hex given but CONFIG_BUILD_OUTPUT_HEX not set ' "in build directory's ({}) .config". format(build_dir)) formats.append('hex') elif args.gen_hex is None and hex_exists: formats.append('hex') # Delegate to the signer. if args.tool == 'imgtool': if args.if_tool_available: self.die('imgtool does not support --if-tool-available') signer = ImgtoolSigner() elif args.tool == 'rimage': signer = RimageSigner() # (Add support for other signers here in elif blocks) else: if args.tool is None: self.die('one --tool is required') else: self.die(f'invalid tool: {args.tool}') signer.sign(self, build_dir, build_conf, formats) class Signer(abc.ABC): '''Common abstract superclass for signers. To add support for a new tool, subclass this and add support for it in the Sign.do_run() method.''' @abc.abstractmethod def sign(self, command, build_dir, build_conf, formats): '''Abstract method to perform a signature; subclasses must implement. :param command: the Sign instance :param build_dir: the build directory :param build_conf: BuildConfiguration for build directory :param formats: list of formats to generate ('bin', 'hex') ''' class ImgtoolSigner(Signer): def sign(self, command, build_dir, build_conf, formats): if not formats: return args = command.args b = pathlib.Path(build_dir) command.wrn("west sign using imgtool is deprecated and will be removed in a future release") imgtool = self.find_imgtool(command, args) # The vector table offset and application version are set in Kconfig: appver = self.get_cfg(command, build_conf, 'CONFIG_MCUBOOT_IMGTOOL_SIGN_VERSION') vtoff = self.get_cfg(command, build_conf, 'CONFIG_ROM_START_OFFSET') # Flash device write alignment and the partition's slot size # come from devicetree: flash = self.edt_flash_node(command, b, args.quiet) align, addr, size = self.edt_flash_params(command, flash) if not build_conf.getboolean('CONFIG_BOOTLOADER_MCUBOOT'): command.wrn("CONFIG_BOOTLOADER_MCUBOOT is not set to y in " f"{build_conf.path}; this probably won't work") kernel = build_conf.get('CONFIG_KERNEL_BIN_NAME', 'zephyr') if 'bin' in formats: in_bin = b / 'zephyr' / f'{kernel}.bin' if not in_bin.is_file(): command.die(f"no unsigned .bin found at {in_bin}") in_bin = os.fspath(in_bin) else: in_bin = None if 'hex' in formats: in_hex = b / 'zephyr' / f'{kernel}.hex' if not in_hex.is_file(): command.die(f"no unsigned .hex found at {in_hex}") in_hex = os.fspath(in_hex) else: in_hex = None if not args.quiet: command.banner('image configuration:') command.inf('partition offset: {0} (0x{0:x})'.format(addr)) command.inf('partition size: {0} (0x{0:x})'.format(size)) command.inf('rom start offset: {0} (0x{0:x})'.format(vtoff)) # Base sign command. sign_base = imgtool + ['sign', '--version', str(appver), '--align', str(align), '--header-size', str(vtoff), '--slot-size', str(size)] sign_base.extend(args.tool_args) if not args.quiet: command.banner('signing binaries') if in_bin: out_bin = args.sbin or str(b / 'zephyr' / 'zephyr.signed.bin') sign_bin = sign_base + [in_bin, out_bin] if not args.quiet: command.inf(f'unsigned bin: {in_bin}') command.inf(f'signed bin: {out_bin}') command.dbg(quote_sh_list(sign_bin)) subprocess.check_call(sign_bin, stdout=subprocess.PIPE if args.quiet else None) if in_hex: out_hex = args.shex or str(b / 'zephyr' / 'zephyr.signed.hex') sign_hex = sign_base + [in_hex, out_hex] if not args.quiet: command.inf(f'unsigned hex: {in_hex}') command.inf(f'signed hex: {out_hex}') command.dbg(quote_sh_list(sign_hex)) subprocess.check_call(sign_hex, stdout=subprocess.PIPE if args.quiet else None) @staticmethod def find_imgtool(cmd, args): if args.tool_path: imgtool = args.tool_path if not os.path.isfile(imgtool): cmd.die(f'--tool-path {imgtool}: no such file') else: imgtool = shutil.which('imgtool') or shutil.which('imgtool.py') if not imgtool: cmd.die('imgtool not found; either install it', '(e.g. "pip3 install imgtool") or provide --tool-path') if platform.system() == 'Windows' and imgtool.endswith('.py'): # Windows users may not be able to run .py files # as executables in subprocesses, regardless of # what the mode says. Always run imgtool as # 'python path/to/imgtool.py' instead of # 'path/to/imgtool.py' in these cases. # https://github.com/zephyrproject-rtos/zephyr/issues/31876 return [sys.executable, imgtool] return [imgtool] @staticmethod def get_cfg(command, build_conf, item): try: return build_conf[item] except KeyError: command.check_force( False, "build .config is missing a {} value".format(item)) return None @staticmethod def edt_flash_node(cmd, b, quiet=False): # Get the EDT Node corresponding to the zephyr,flash chosen DT # node; 'b' is the build directory as a pathlib object. # Ensure the build directory has a compiled DTS file # where we expect it to be. dts = b / 'zephyr' / 'zephyr.dts' if not quiet: cmd.dbg('DTS file:', dts, level=Verbosity.DBG_MORE) edt_pickle = b / 'zephyr' / 'edt.pickle' if not edt_pickle.is_file(): cmd.die("can't load devicetree; expected to find:", edt_pickle) # Load the devicetree. with open(edt_pickle, 'rb') as f: edt = pickle.load(f) # By convention, the zephyr,flash chosen node contains the # partition information about the zephyr image to sign. flash = edt.chosen_node('zephyr,flash') if not flash: cmd.die('devicetree has no chosen zephyr,flash node;', "can't infer flash write block or slot0_partition slot sizes") return flash @staticmethod def edt_flash_params(cmd, flash): # Get the flash device's write alignment and offset from the # slot0_partition and the size from slot1_partition , out of the # build directory's devicetree. slot1_partition size is used, # when available, because in swap-move mode it can be one sector # smaller. When not available, fallback to slot0_partition (single slot dfu). # The node must have a "partitions" child node, which in turn # must have child nodes with label slot0_partition and may have a child node # with label slot1_partition. By convention, the slots for consumption by # imgtool are linked into these partitions. if 'partitions' not in flash.children: cmd.die("DT zephyr,flash chosen node has no partitions,", "can't find partitions for MCUboot slots") partitions = flash.children['partitions'] slots = { label: node for node in partitions.children.values() for label in node.labels if label in set(['slot0_partition', 'slot1_partition']) } if 'slot0_partition' not in slots: cmd.die("DT zephyr,flash chosen node has no slot0_partition partition,", "can't determine its address") # Die on missing or zero alignment or slot_size. if "write-block-size" not in flash.props: cmd.die('DT zephyr,flash node has no write-block-size;', "can't determine imgtool write alignment") align = flash.props['write-block-size'].val if align == 0: cmd.die('expected nonzero flash alignment, but got ' 'DT flash device write-block-size {}'.format(align)) # The partitions node, and its subnode, must provide # the size of slot1_partition or slot0_partition partition via the regs property. slot_key = 'slot1_partition' if 'slot1_partition' in slots else 'slot0_partition' if not slots[slot_key].regs: cmd.die(f'{slot_key} flash partition has no regs property;', "can't determine size of slot") # always use addr of slot0_partition, which is where slots are run addr = slots['slot0_partition'].regs[0].addr size = slots[slot_key].regs[0].size if size == 0: cmd.die('expected nonzero slot size for {}'.format(slot_key)) return (align, addr, size) class RimageSigner(Signer): def rimage_config_dir(self): 'Returns the rimage/config/ directory with the highest precedence' args = self.command.args if args.tool_data: conf_dir = pathlib.Path(args.tool_data) elif self.cmake_cache.get('RIMAGE_CONFIG_PATH'): conf_dir = pathlib.Path(self.cmake_cache['RIMAGE_CONFIG_PATH']) else: conf_dir = self.sof_src_dir / 'tools' / 'rimage' / 'config' self.command.dbg(f'rimage config directory={conf_dir}') return conf_dir def preprocess_toml(self, config_dir, toml_basename, subdir): 'Runs the C pre-processor on config_dir/toml_basename.h' compiler_path = self.cmake_cache.get("CMAKE_C_COMPILER") preproc_cmd = [compiler_path, '-E', str(config_dir / (toml_basename + '.h'))] # -P removes line markers to keep the .toml output reproducible. To # trace #includes, temporarily comment out '-P' (-f*-prefix-map # unfortunately don't seem to make any difference here and they're # gcc-specific) preproc_cmd += ['-P'] # "REM" escapes _leading_ '#' characters from cpp and allows # such comments to be preserved in generated/*.toml files: # # REM # my comment... # # Note _trailing_ '#' characters and comments are ignored by cpp # and don't need any REM trick. preproc_cmd += ['-DREM='] preproc_cmd += ['-I', str(self.sof_src_dir / 'src')] preproc_cmd += ['-imacros', str(pathlib.Path('zephyr') / 'include' / 'generated' / 'zephyr' / 'autoconf.h')] preproc_cmd += ['-o', str(subdir / 'rimage_config.toml')] self.command.inf(quote_sh_list(preproc_cmd)) subprocess.run(preproc_cmd, check=True, cwd=self.build_dir) def sign(self, command, build_dir, build_conf, formats): self.command = command args = command.args b = pathlib.Path(build_dir) self.build_dir = b cache = CMakeCache.from_build_dir(build_dir) self.cmake_cache = cache # Warning: RIMAGE_TARGET in Zephyr is a duplicate of # CONFIG_RIMAGE_SIGNING_SCHEMA in SOF. target = cache.get('RIMAGE_TARGET') if not target: msg = 'rimage target not defined in board.cmake' if args.if_tool_available: command.inf(msg) sys.exit(0) else: command.die(msg) kernel_name = build_conf.get('CONFIG_KERNEL_BIN_NAME', 'zephyr') # TODO: make this a new sign.py --bootloader option. if target in ('imx8', 'imx8m', 'imx8ulp', 'imx95'): bootloader = None kernel = str(b / 'zephyr' / f'{kernel_name}.elf') out_bin = str(b / 'zephyr' / f'{kernel_name}.ri') out_xman = str(b / 'zephyr' / f'{kernel_name}.ri.xman') out_tmp = str(b / 'zephyr' / f'{kernel_name}.rix') else: bootloader = str(b / 'zephyr' / 'boot.mod') kernel = str(b / 'zephyr' / 'main.mod') out_bin = str(b / 'zephyr' / f'{kernel_name}.ri') out_xman = str(b / 'zephyr' / f'{kernel_name}.ri.xman') out_tmp = str(b / 'zephyr' / f'{kernel_name}.rix') # Clean any stale output. This is especially important when using --if-tool-available # (but not just) for o in [ out_bin, out_xman, out_tmp ]: pathlib.Path(o).unlink(missing_ok=True) tool_path = ( args.tool_path if args.tool_path else command.config_get('rimage.path', None) ) err_prefix = '--tool-path' if args.tool_path else 'west config' if tool_path: command.check_force(shutil.which(tool_path), f'{err_prefix} {tool_path}: not an executable') else: tool_path = shutil.which('rimage') if not tool_path: err_msg = 'rimage not found; either install it or provide --tool-path' if args.if_tool_available: command.wrn(err_msg) command.wrn('zephyr binary _not_ signed!') return else: command.die(err_msg) #### -c sof/rimage/config/signing_schema.toml #### if not args.quiet: command.inf('Signing with tool {}'.format(tool_path)) try: sof_proj = command.manifest.get_projects(['sof'], allow_paths=False) sof_src_dir = pathlib.Path(sof_proj[0].abspath) except ValueError: # sof is the manifest sof_src_dir = pathlib.Path(manifest.manifest_path()).parent self.sof_src_dir = sof_src_dir command.inf('Signing for SOC target ' + target) # FIXME: deprecate --no-manifest and replace it with a much # simpler and more direct `-- -e` which the user can _already_ # pass today! With unclear consequences right now... if '--no-manifest' in args.tool_args: no_manifest = True args.tool_args.remove('--no-manifest') else: no_manifest = False # Non-SOF build does not have extended manifest data for # rimage to process, which might result in rimage error. # So skip it when not doing SOF builds. is_sof_build = build_conf.getboolean('CONFIG_SOF') if not is_sof_build: no_manifest = True if no_manifest: extra_ri_args = [ ] else: extra_ri_args = ['-e'] sign_base = [tool_path] # Align rimage verbosity. # Sub-command arg 'west sign -q' takes precedence over west '-v' if not args.quiet and args.verbose: sign_base += ['-v'] * args.verbose components = [ ] if bootloader is None else [ bootloader ] components += [ kernel ] sign_config_extra_args = command.config_get_words('rimage.extra-args', []) if '-k' not in sign_config_extra_args + args.tool_args: # rimage requires a key argument even when it does not sign cmake_default_key = cache.get('RIMAGE_SIGN_KEY', 'key placeholder from sign.py') extra_ri_args += [ '-k', str(sof_src_dir / 'keys' / cmake_default_key) ] if args.tool_data and '-c' in args.tool_args: command.wrn('--tool-data ' + args.tool_data + ' ignored! Overridden by: -- -c ... ') if '-c' not in sign_config_extra_args + args.tool_args: conf_dir = self.rimage_config_dir() toml_basename = target + '.toml' if ((conf_dir / toml_basename).exists() and (conf_dir / (toml_basename + '.h')).exists()): command.die(f"Cannot have both {toml_basename + '.h'} and {toml_basename} in {conf_dir}") if (conf_dir / (toml_basename + '.h')).exists(): generated_subdir = pathlib.Path('zephyr') / 'misc' / 'generated' self.preprocess_toml(conf_dir, toml_basename, generated_subdir) extra_ri_args += ['-c', str(b / generated_subdir / 'rimage_config.toml')] else: toml_dir = conf_dir extra_ri_args += ['-c', str(toml_dir / toml_basename)] # Warning: while not officially supported (yet?), the rimage --option that is last # on the command line currently wins in case of duplicate options. So pay # attention to the _args order below. sign_base += (['-o', out_bin] + sign_config_extra_args + extra_ri_args + args.tool_args + components) command.inf(quote_sh_list(sign_base)) subprocess.check_call(sign_base) if no_manifest: filenames = [out_bin] else: filenames = [out_xman, out_bin] if not args.quiet: command.inf('Prefixing ' + out_bin + ' with manifest ' + out_xman) with open(out_tmp, 'wb') as outfile: for fname in filenames: with open(fname, 'rb') as infile: outfile.write(infile.read()) os.remove(out_bin) os.rename(out_tmp, out_bin)