1# Copyright (c) 2018 Foundries.io
2#
3# SPDX-License-Identifier: Apache-2.0
4
5import abc
6import argparse
7import os
8import pathlib
9import pickle
10import platform
11import shutil
12import shlex
13import subprocess
14import sys
15
16from west import log
17from west import manifest
18from west.util import quote_sh_list
19
20from build_helpers import find_build_dir, is_zephyr_build, \
21    FIND_BUILD_DIR_DESCRIPTION
22from runners.core import BuildConfiguration
23from zcmake import CMakeCache
24from zephyr_ext_common import Forceable, ZEPHYR_SCRIPTS
25
26# This is needed to load edt.pickle files.
27sys.path.insert(0, str(ZEPHYR_SCRIPTS / 'dts' / 'python-devicetree' / 'src'))
28
29SIGN_DESCRIPTION = '''\
30This command automates some of the drudgery of creating signed Zephyr
31binaries for chain-loading by a bootloader.
32
33In the simplest usage, run this from your build directory:
34
35   west sign -t your_tool -- ARGS_FOR_YOUR_TOOL
36
37The "ARGS_FOR_YOUR_TOOL" value can be any additional arguments you want to
38pass to the tool, such as the location of a signing key etc. Depending on
39which sort of ARGS_FOR_YOUR_TOOLS you use, the `--` separator/sentinel may
40not always be required. To avoid ambiguity and having to find and
41understand POSIX 12.2 Guideline 10, always use `--`.
42
43See tool-specific help below for details.'''
44
45SIGN_EPILOG = '''\
46imgtool
47-------
48
49To build a signed binary you can load with MCUboot using imgtool,
50run this from your build directory:
51
52   west sign -t imgtool -- --key YOUR_SIGNING_KEY.pem
53
54For this to work, either imgtool must be installed (e.g. using pip3),
55or you must pass the path to imgtool.py using the -p option.
56
57Assuming your binary was properly built for processing and handling by
58imgtool, this creates zephyr.signed.bin and zephyr.signed.hex
59files which are ready for use by your bootloader.
60
61The version number, image header size, alignment, and slot sizes are
62determined from the build directory using .config and the device tree.
63As shown above, extra arguments after a '--' are passed to imgtool
64directly.
65
66rimage
67------
68
69To create a signed binary with the rimage tool, run this from your build
70directory:
71
72   west sign -t rimage -- -k YOUR_SIGNING_KEY.pem
73
74For this to work, either rimage must be installed or you must pass
75the path to rimage using the -p option.
76
77You can also pass additional arguments to rimage thanks to [sign] and
78[rimage] sections in your west config file(s); this is especially useful
79when invoking west sign _indirectly_ through CMake/ninja. See how at
80https://docs.zephyrproject.org/latest/develop/west/sign.html
81'''
82
83
84def config_get_words(west_config, section_key, fallback=None):
85    unparsed = west_config.get(section_key)
86    log.dbg(f'west config {section_key}={unparsed}')
87    return fallback if unparsed is None else shlex.split(unparsed)
88
89
90def config_get(west_config, section_key, fallback=None):
91    words = config_get_words(west_config, section_key)
92    if words is None:
93        return fallback
94    if len(words) != 1:
95        log.die(f'Single word expected for: {section_key}={words}. Use quotes?')
96    return words[0]
97
98
99class ToggleAction(argparse.Action):
100
101    def __call__(self, parser, args, ignored, option):
102        setattr(args, self.dest, not option.startswith('--no-'))
103
104
105class Sign(Forceable):
106    def __init__(self):
107        super(Sign, self).__init__(
108            'sign',
109            # Keep this in sync with the string in west-commands.yml.
110            'sign a Zephyr binary for bootloader chain-loading',
111            SIGN_DESCRIPTION,
112            accepts_unknown_args=False)
113
114    def do_add_parser(self, parser_adder):
115        parser = parser_adder.add_parser(
116            self.name,
117            epilog=SIGN_EPILOG,
118            help=self.help,
119            formatter_class=argparse.RawDescriptionHelpFormatter,
120            description=self.description)
121
122        parser.add_argument('-d', '--build-dir',
123                            help=FIND_BUILD_DIR_DESCRIPTION)
124        parser.add_argument('-q', '--quiet', action='store_true',
125                            help='suppress non-error output')
126        self.add_force_arg(parser)
127
128        # general options
129        group = parser.add_argument_group('tool control options')
130        group.add_argument('-t', '--tool', choices=['imgtool', 'rimage'],
131                           help='''image signing tool name; imgtool and rimage
132                           are currently supported''')
133        group.add_argument('-p', '--tool-path', default=None,
134                           help='''path to the tool itself, if needed''')
135        group.add_argument('-D', '--tool-data', default=None,
136                           help='''path to a tool-specific data/configuration directory, if needed''')
137        group.add_argument('--if-tool-available', action='store_true',
138                           help='''Do not fail if the rimage tool is not found or the rimage signing
139schema (rimage "target") is not defined in board.cmake.''')
140        group.add_argument('tool_args', nargs='*', metavar='tool_opt',
141                           help='extra option(s) to pass to the signing tool')
142
143        # bin file options
144        group = parser.add_argument_group('binary (.bin) file options')
145        group.add_argument('--bin', '--no-bin', dest='gen_bin', nargs=0,
146                           action=ToggleAction,
147                           help='''produce a signed .bin file?
148                           (default: yes, if supported and unsigned bin
149                           exists)''')
150        group.add_argument('-B', '--sbin', metavar='BIN',
151                           help='''signed .bin file name
152                           (default: zephyr.signed.bin in the build
153                           directory, next to zephyr.bin)''')
154
155        # hex file options
156        group = parser.add_argument_group('Intel HEX (.hex) file options')
157        group.add_argument('--hex', '--no-hex', dest='gen_hex', nargs=0,
158                           action=ToggleAction,
159                           help='''produce a signed .hex file?
160                           (default: yes, if supported and unsigned hex
161                           exists)''')
162        group.add_argument('-H', '--shex', metavar='HEX',
163                           help='''signed .hex file name
164                           (default: zephyr.signed.hex in the build
165                           directory, next to zephyr.hex)''')
166
167        return parser
168
169    def do_run(self, args, ignored):
170        self.args = args        # for check_force
171
172        # Find the build directory and parse .config and DT.
173        build_dir = find_build_dir(args.build_dir)
174        self.check_force(os.path.isdir(build_dir),
175                         'no such build directory {}'.format(build_dir))
176        self.check_force(is_zephyr_build(build_dir),
177                         "build directory {} doesn't look like a Zephyr build "
178                         'directory'.format(build_dir))
179        build_conf = BuildConfiguration(build_dir)
180
181        if not args.tool:
182            args.tool = config_get(self.config, 'sign.tool')
183
184        # Decide on output formats.
185        formats = []
186        bin_exists = build_conf.getboolean('CONFIG_BUILD_OUTPUT_BIN')
187        if args.gen_bin:
188            self.check_force(bin_exists,
189                             '--bin given but CONFIG_BUILD_OUTPUT_BIN not set '
190                             "in build directory's ({}) .config".
191                             format(build_dir))
192            formats.append('bin')
193        elif args.gen_bin is None and bin_exists:
194            formats.append('bin')
195
196        hex_exists = build_conf.getboolean('CONFIG_BUILD_OUTPUT_HEX')
197        if args.gen_hex:
198            self.check_force(hex_exists,
199                             '--hex given but CONFIG_BUILD_OUTPUT_HEX not set '
200                             "in build directory's ({}) .config".
201                             format(build_dir))
202            formats.append('hex')
203        elif args.gen_hex is None and hex_exists:
204            formats.append('hex')
205
206        # Delegate to the signer.
207        if args.tool == 'imgtool':
208            if args.if_tool_available:
209                log.die('imgtool does not support --if-tool-available')
210            signer = ImgtoolSigner()
211        elif args.tool == 'rimage':
212            signer = RimageSigner()
213        # (Add support for other signers here in elif blocks)
214        else:
215            if args.tool is None:
216                log.die('one --tool is required')
217            else:
218                log.die(f'invalid tool: {args.tool}')
219
220        signer.sign(self, build_dir, build_conf, formats)
221
222
223class Signer(abc.ABC):
224    '''Common abstract superclass for signers.
225
226    To add support for a new tool, subclass this and add support for
227    it in the Sign.do_run() method.'''
228
229    @abc.abstractmethod
230    def sign(self, command, build_dir, build_conf, formats):
231        '''Abstract method to perform a signature; subclasses must implement.
232
233        :param command: the Sign instance
234        :param build_dir: the build directory
235        :param build_conf: BuildConfiguration for build directory
236        :param formats: list of formats to generate ('bin', 'hex')
237        '''
238
239
240class ImgtoolSigner(Signer):
241
242    def sign(self, command, build_dir, build_conf, formats):
243        if not formats:
244            return
245
246        args = command.args
247        b = pathlib.Path(build_dir)
248
249        imgtool = self.find_imgtool(command, args)
250        # The vector table offset and application version are set in Kconfig:
251        appver = self.get_cfg(command, build_conf, 'CONFIG_MCUBOOT_IMGTOOL_SIGN_VERSION')
252        vtoff = self.get_cfg(command, build_conf, 'CONFIG_ROM_START_OFFSET')
253        # Flash device write alignment and the partition's slot size
254        # come from devicetree:
255        flash = self.edt_flash_node(b, args.quiet)
256        align, addr, size = self.edt_flash_params(flash)
257
258        if not build_conf.getboolean('CONFIG_BOOTLOADER_MCUBOOT'):
259            log.wrn("CONFIG_BOOTLOADER_MCUBOOT is not set to y in "
260                    f"{build_conf.path}; this probably won't work")
261
262        kernel = build_conf.get('CONFIG_KERNEL_BIN_NAME', 'zephyr')
263
264        if 'bin' in formats:
265            in_bin = b / 'zephyr' / f'{kernel}.bin'
266            if not in_bin.is_file():
267                log.die(f"no unsigned .bin found at {in_bin}")
268            in_bin = os.fspath(in_bin)
269        else:
270            in_bin = None
271        if 'hex' in formats:
272            in_hex = b / 'zephyr' / f'{kernel}.hex'
273            if not in_hex.is_file():
274                log.die(f"no unsigned .hex found at {in_hex}")
275            in_hex = os.fspath(in_hex)
276        else:
277            in_hex = None
278
279        if not args.quiet:
280            log.banner('image configuration:')
281            log.inf('partition offset: {0} (0x{0:x})'.format(addr))
282            log.inf('partition size: {0} (0x{0:x})'.format(size))
283            log.inf('rom start offset: {0} (0x{0:x})'.format(vtoff))
284
285        # Base sign command.
286        sign_base = imgtool + ['sign',
287                               '--version', str(appver),
288                               '--align', str(align),
289                               '--header-size', str(vtoff),
290                               '--slot-size', str(size)]
291        sign_base.extend(args.tool_args)
292
293        if not args.quiet:
294            log.banner('signing binaries')
295        if in_bin:
296            out_bin = args.sbin or str(b / 'zephyr' / 'zephyr.signed.bin')
297            sign_bin = sign_base + [in_bin, out_bin]
298            if not args.quiet:
299                log.inf(f'unsigned bin: {in_bin}')
300                log.inf(f'signed bin:   {out_bin}')
301                log.dbg(quote_sh_list(sign_bin))
302            subprocess.check_call(sign_bin, stdout=subprocess.PIPE if args.quiet else None)
303        if in_hex:
304            out_hex = args.shex or str(b / 'zephyr' / 'zephyr.signed.hex')
305            sign_hex = sign_base + [in_hex, out_hex]
306            if not args.quiet:
307                log.inf(f'unsigned hex: {in_hex}')
308                log.inf(f'signed hex:   {out_hex}')
309                log.dbg(quote_sh_list(sign_hex))
310            subprocess.check_call(sign_hex, stdout=subprocess.PIPE if args.quiet else None)
311
312    @staticmethod
313    def find_imgtool(command, args):
314        if args.tool_path:
315            imgtool = args.tool_path
316            if not os.path.isfile(imgtool):
317                log.die(f'--tool-path {imgtool}: no such file')
318        else:
319            imgtool = shutil.which('imgtool') or shutil.which('imgtool.py')
320            if not imgtool:
321                log.die('imgtool not found; either install it',
322                        '(e.g. "pip3 install imgtool") or provide --tool-path')
323
324        if platform.system() == 'Windows' and imgtool.endswith('.py'):
325            # Windows users may not be able to run .py files
326            # as executables in subprocesses, regardless of
327            # what the mode says. Always run imgtool as
328            # 'python path/to/imgtool.py' instead of
329            # 'path/to/imgtool.py' in these cases.
330            # https://github.com/zephyrproject-rtos/zephyr/issues/31876
331            return [sys.executable, imgtool]
332
333        return [imgtool]
334
335    @staticmethod
336    def get_cfg(command, build_conf, item):
337        try:
338            return build_conf[item]
339        except KeyError:
340            command.check_force(
341                False, "build .config is missing a {} value".format(item))
342            return None
343
344    @staticmethod
345    def edt_flash_node(b, quiet=False):
346        # Get the EDT Node corresponding to the zephyr,flash chosen DT
347        # node; 'b' is the build directory as a pathlib object.
348
349        # Ensure the build directory has a compiled DTS file
350        # where we expect it to be.
351        dts = b / 'zephyr' / 'zephyr.dts'
352        if not quiet:
353            log.dbg('DTS file:', dts, level=log.VERBOSE_VERY)
354        edt_pickle = b / 'zephyr' / 'edt.pickle'
355        if not edt_pickle.is_file():
356            log.die("can't load devicetree; expected to find:", edt_pickle)
357
358        # Load the devicetree.
359        with open(edt_pickle, 'rb') as f:
360            edt = pickle.load(f)
361
362        # By convention, the zephyr,flash chosen node contains the
363        # partition information about the zephyr image to sign.
364        flash = edt.chosen_node('zephyr,flash')
365        if not flash:
366            log.die('devicetree has no chosen zephyr,flash node;',
367                    "can't infer flash write block or slot0_partition slot sizes")
368
369        return flash
370
371    @staticmethod
372    def edt_flash_params(flash):
373        # Get the flash device's write alignment and offset from the
374        # slot0_partition and the size from slot1_partition , out of the
375        # build directory's devicetree. slot1_partition size is used,
376        # when available, because in swap-move mode it can be one sector
377        # smaller. When not available, fallback to slot0_partition (single slot dfu).
378
379        # The node must have a "partitions" child node, which in turn
380        # must have child nodes with label slot0_partition and may have a child node
381        # with label slot1_partition. By convention, the slots for consumption by
382        # imgtool are linked into these partitions.
383        if 'partitions' not in flash.children:
384            log.die("DT zephyr,flash chosen node has no partitions,",
385                    "can't find partitions for MCUboot slots")
386
387        partitions = flash.children['partitions']
388        slots = {
389            label: node for node in partitions.children.values()
390                        for label in node.labels
391                        if label in set(['slot0_partition', 'slot1_partition'])
392        }
393
394        if 'slot0_partition' not in slots:
395            log.die("DT zephyr,flash chosen node has no slot0_partition partition,",
396                    "can't determine its address")
397
398        # Die on missing or zero alignment or slot_size.
399        if "write-block-size" not in flash.props:
400            log.die('DT zephyr,flash node has no write-block-size;',
401                    "can't determine imgtool write alignment")
402        align = flash.props['write-block-size'].val
403        if align == 0:
404            log.die('expected nonzero flash alignment, but got '
405                    'DT flash device write-block-size {}'.format(align))
406
407        # The partitions node, and its subnode, must provide
408        # the size of slot1_partition or slot0_partition partition via the regs property.
409        slot_key = 'slot1_partition' if 'slot1_partition' in slots else 'slot0_partition'
410        if not slots[slot_key].regs:
411            log.die(f'{slot_key} flash partition has no regs property;',
412                    "can't determine size of slot")
413
414        # always use addr of slot0_partition, which is where slots are run
415        addr = slots['slot0_partition'].regs[0].addr
416
417        size = slots[slot_key].regs[0].size
418        if size == 0:
419            log.die('expected nonzero slot size for {}'.format(slot_key))
420
421        return (align, addr, size)
422
423class RimageSigner(Signer):
424
425    def rimage_config_dir(self):
426        'Returns the rimage/config/ directory with the highest precedence'
427        args = self.command.args
428        if args.tool_data:
429            conf_dir = pathlib.Path(args.tool_data)
430        elif self.cmake_cache.get('RIMAGE_CONFIG_PATH'):
431            conf_dir = pathlib.Path(self.cmake_cache['RIMAGE_CONFIG_PATH'])
432        else:
433            conf_dir = self.sof_src_dir / 'tools' / 'rimage' / 'config'
434        self.command.dbg(f'rimage config directory={conf_dir}')
435        return conf_dir
436
437    def preprocess_toml(self, config_dir, toml_basename, subdir):
438        'Runs the C pre-processor on config_dir/toml_basename.h'
439
440        compiler_path = self.cmake_cache.get("CMAKE_C_COMPILER")
441        preproc_cmd = [compiler_path, '-E', str(config_dir / (toml_basename + '.h'))]
442        # -P removes line markers to keep the .toml output reproducible.  To
443        # trace #includes, temporarily comment out '-P' (-f*-prefix-map
444        # unfortunately don't seem to make any difference here and they're
445        # gcc-specific)
446        preproc_cmd += ['-P']
447
448        # "REM" escapes _leading_ '#' characters from cpp and allows
449        # such comments to be preserved in generated/*.toml files:
450        #
451        #      REM # my comment...
452        #
453        # Note _trailing_ '#' characters and comments are ignored by cpp
454        # and don't need any REM trick.
455        preproc_cmd += ['-DREM=']
456
457        preproc_cmd += ['-I', str(self.sof_src_dir / 'src')]
458        preproc_cmd += ['-imacros',
459                        str(pathlib.Path('zephyr') / 'include' / 'generated' / 'zephyr' / 'autoconf.h')]
460        preproc_cmd += ['-o', str(subdir / 'rimage_config.toml')]
461        self.command.inf(quote_sh_list(preproc_cmd))
462        subprocess.run(preproc_cmd, check=True, cwd=self.build_dir)
463
464    def sign(self, command, build_dir, build_conf, formats):
465        self.command = command
466        args = command.args
467
468        b = pathlib.Path(build_dir)
469        self.build_dir = b
470        cache = CMakeCache.from_build_dir(build_dir)
471        self.cmake_cache = cache
472
473        # Warning: RIMAGE_TARGET in Zephyr is a duplicate of
474        # CONFIG_RIMAGE_SIGNING_SCHEMA in SOF.
475        target = cache.get('RIMAGE_TARGET')
476
477        if not target:
478            msg = 'rimage target not defined in board.cmake'
479            if args.if_tool_available:
480                log.inf(msg)
481                sys.exit(0)
482            else:
483                log.die(msg)
484
485        kernel_name = build_conf.get('CONFIG_KERNEL_BIN_NAME', 'zephyr')
486
487        # TODO: make this a new sign.py --bootloader option.
488        if target in ('imx8', 'imx8m', 'imx8ulp'):
489            bootloader = None
490            kernel = str(b / 'zephyr' / f'{kernel_name}.elf')
491            out_bin = str(b / 'zephyr' / f'{kernel_name}.ri')
492            out_xman = str(b / 'zephyr' / f'{kernel_name}.ri.xman')
493            out_tmp = str(b / 'zephyr' / f'{kernel_name}.rix')
494        else:
495            bootloader = str(b / 'zephyr' / 'boot.mod')
496            kernel = str(b / 'zephyr' / 'main.mod')
497            out_bin = str(b / 'zephyr' / f'{kernel_name}.ri')
498            out_xman = str(b / 'zephyr' / f'{kernel_name}.ri.xman')
499            out_tmp = str(b / 'zephyr' / f'{kernel_name}.rix')
500
501        # Clean any stale output. This is especially important when using --if-tool-available
502        # (but not just)
503        for o in [ out_bin, out_xman, out_tmp ]:
504            pathlib.Path(o).unlink(missing_ok=True)
505
506        tool_path = (
507            args.tool_path if args.tool_path else
508            config_get(command.config, 'rimage.path', None)
509        )
510        err_prefix = '--tool-path' if args.tool_path else 'west config'
511
512        if tool_path:
513            command.check_force(shutil.which(tool_path),
514                                f'{err_prefix} {tool_path}: not an executable')
515        else:
516            tool_path = shutil.which('rimage')
517            if not tool_path:
518                err_msg = 'rimage not found; either install it or provide --tool-path'
519                if args.if_tool_available:
520                    log.wrn(err_msg)
521                    log.wrn('zephyr binary _not_ signed!')
522                    return
523                else:
524                    log.die(err_msg)
525
526        #### -c sof/rimage/config/signing_schema.toml  ####
527
528        if not args.quiet:
529            log.inf('Signing with tool {}'.format(tool_path))
530
531        try:
532            sof_proj = command.manifest.get_projects(['sof'], allow_paths=False)
533            sof_src_dir = pathlib.Path(sof_proj[0].abspath)
534        except ValueError: # sof is the manifest
535            sof_src_dir = pathlib.Path(manifest.manifest_path()).parent
536
537        self.sof_src_dir = sof_src_dir
538
539
540        log.inf('Signing for SOC target ' + target)
541
542        # FIXME: deprecate --no-manifest and replace it with a much
543        # simpler and more direct `-- -e` which the user can _already_
544        # pass today! With unclear consequences right now...
545        if '--no-manifest' in args.tool_args:
546            no_manifest = True
547            args.tool_args.remove('--no-manifest')
548        else:
549            no_manifest = False
550
551        # Non-SOF build does not have extended manifest data for
552        # rimage to process, which might result in rimage error.
553        # So skip it when not doing SOF builds.
554        is_sof_build = build_conf.getboolean('CONFIG_SOF')
555        if not is_sof_build:
556            no_manifest = True
557
558        if no_manifest:
559            extra_ri_args = [ ]
560        else:
561            extra_ri_args = ['-e']
562
563        sign_base = [tool_path]
564
565        # Align rimage verbosity.
566        # Sub-command arg 'west sign -q' takes precedence over west '-v'
567        if not args.quiet and args.verbose:
568            sign_base += ['-v'] * args.verbose
569
570        components = [ ] if bootloader is None else [ bootloader ]
571        components += [ kernel ]
572
573        sign_config_extra_args = config_get_words(command.config, 'rimage.extra-args', [])
574
575        if '-k' not in sign_config_extra_args + args.tool_args:
576            # rimage requires a key argument even when it does not sign
577            cmake_default_key = cache.get('RIMAGE_SIGN_KEY', 'key placeholder from sign.py')
578            extra_ri_args += [ '-k', str(sof_src_dir / 'keys' / cmake_default_key) ]
579
580        if args.tool_data and '-c' in args.tool_args:
581            log.wrn('--tool-data ' + args.tool_data + ' ignored! Overridden by: -- -c ... ')
582
583        if '-c' not in sign_config_extra_args + args.tool_args:
584            conf_dir = self.rimage_config_dir()
585            toml_basename = target + '.toml'
586            if ((conf_dir / toml_basename).exists() and
587               (conf_dir / (toml_basename + '.h')).exists()):
588                command.die(f"Cannot have both {toml_basename + '.h'} and {toml_basename} in {conf_dir}")
589
590            if (conf_dir / (toml_basename + '.h')).exists():
591                generated_subdir = pathlib.Path('zephyr') / 'misc' / 'generated'
592                self.preprocess_toml(conf_dir, toml_basename, generated_subdir)
593                extra_ri_args += ['-c', str(b / generated_subdir / 'rimage_config.toml')]
594            else:
595                toml_dir = conf_dir
596                extra_ri_args += ['-c', str(toml_dir / toml_basename)]
597
598        # Warning: while not officially supported (yet?), the rimage --option that is last
599        # on the command line currently wins in case of duplicate options. So pay
600        # attention to the _args order below.
601        sign_base += (['-o', out_bin] + sign_config_extra_args +
602                      extra_ri_args + args.tool_args + components)
603
604        command.inf(quote_sh_list(sign_base))
605        subprocess.check_call(sign_base)
606
607        if no_manifest:
608            filenames = [out_bin]
609        else:
610            filenames = [out_xman, out_bin]
611            if not args.quiet:
612                log.inf('Prefixing ' + out_bin + ' with manifest ' + out_xman)
613        with open(out_tmp, 'wb') as outfile:
614            for fname in filenames:
615                with open(fname, 'rb') as infile:
616                    outfile.write(infile.read())
617
618        os.remove(out_bin)
619        os.rename(out_tmp, out_bin)
620