From c7d723f872730680e87953aded6fc6e2d2263b2d Mon Sep 17 00:00:00 2001 From: Daniel DeGrasse Date: Sat, 27 Sep 2025 08:52:30 -0500 Subject: [PATCH 1/5] [nrf fromtree] imgtool: support producing images in test mode Add --test flag, which allows users to append a trailer that marks the image as ready for a test swap. This can be used for cases where the user wants to load an image to flash that MCUBoot will boot in test mode after system reset. Signed-off-by: Daniel DeGrasse (cherry picked from commit 48b0f6da9af8d009eb8eafba023998a7d85320a1) Signed-off-by: Dominik Ermel --- scripts/imgtool/image.py | 17 +++++++++++------ scripts/imgtool/main.py | 9 ++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/scripts/imgtool/image.py b/scripts/imgtool/image.py index c2f51ae7e4..bd03ad0999 100755 --- a/scripts/imgtool/image.py +++ b/scripts/imgtool/image.py @@ -349,8 +349,8 @@ def __repr__(self): class Image: def __init__(self, version=None, header_size=IMAGE_HEADER_SIZE, - pad_header=False, pad=False, confirm=False, align=1, - slot_size=0, max_sectors=DEFAULT_MAX_SECTORS, + pad_header=False, pad=False, confirm=False, test=False, + align=1, slot_size=0, max_sectors=DEFAULT_MAX_SECTORS, overwrite_only=False, endian="little", load_addr=0, rom_fixed=None, erased_val=None, save_enctlv=False, security_counter=None, max_align=None, @@ -368,6 +368,7 @@ def __init__(self, version=None, header_size=IMAGE_HEADER_SIZE, self.pad_header = pad_header self.pad = pad self.confirm = confirm + self.test = test self.align = align self.slot_size = slot_size self.max_sectors = max_sectors @@ -506,12 +507,14 @@ def save(self, path, hex_addr=None): self.save_enctlv, self.enctlv_len) trailer_addr = (self.base_addr + self.slot_size) - trailer_size - if self.confirm and not self.overwrite_only: + if (self.test or self.confirm) and not self.overwrite_only: magic_align_size = align_up(len(self.boot_magic), self.max_align) image_ok_idx = -(magic_align_size + self.max_align) + # If test is set, we leave image_ok at the erased value flag = bytearray([self.erased_val] * self.max_align) - flag[0] = 0x01 # image_ok = 0x01 + if self.confirm: + flag[0] = 0x01 # image_ok = 0x01 h.puts(trailer_addr + trailer_size + image_ok_idx, bytes(flag)) h.puts(trailer_addr + (trailer_size - len(self.boot_magic)), @@ -950,11 +953,13 @@ def pad_to(self, size): pbytes = bytearray([self.erased_val] * padding) pbytes += bytearray([self.erased_val] * (tsize - len(self.boot_magic))) pbytes += self.boot_magic - if self.confirm and not self.overwrite_only: + if (self.test or self.confirm) and not self.overwrite_only: magic_size = 16 magic_align_size = align_up(magic_size, self.max_align) image_ok_idx = -(magic_align_size + self.max_align) - pbytes[image_ok_idx] = 0x01 # image_ok = 0x01 + # If test is set, set leave image_ok at the erased value + if self.confirm: + pbytes[image_ok_idx] = 0x01 # image_ok = 0x01 self.payload += pbytes @staticmethod diff --git a/scripts/imgtool/main.py b/scripts/imgtool/main.py index 5560a4612e..5f102b9ea5 100755 --- a/scripts/imgtool/main.py +++ b/scripts/imgtool/main.py @@ -388,6 +388,9 @@ def convert(self, value, param, ctx): @click.option('--confirm', default=False, is_flag=True, help='When padding the image, mark it as confirmed (implies ' '--pad)') +@click.option('--test', default=False, is_flag=True, + help='When padding the image, mark it for a test swap (implies ' + '--pad)') @click.option('--pad', default=False, is_flag=True, help='Pad image to --slot-size bytes, adding trailer magic') @click.option('-S', '--slot-size', type=BasedIntParamType(), required=True, @@ -464,7 +467,7 @@ def convert(self, value, param, ctx): @click.option('--compression-lzma-preset', type=int, default=9, help='LZMA - compression level preset', show_default=True) def sign(key, public_key_format, align, version, pad_sig, header_size, - pad_header, slot_size, pad, confirm, max_sectors, overwrite_only, + pad_header, slot_size, pad, confirm, test, max_sectors, overwrite_only, endian, encrypt_keylen, encrypt, compression, infile, outfile, dependencies, load_addr, hex_addr, erased_val, save_enctlv, security_counter, boot_record, custom_tlv, custom_tlv_file, rom_fixed, max_align, @@ -472,13 +475,13 @@ def sign(key, public_key_format, align, version, pad_sig, header_size, vector_to_sign, non_bootable, vid, cid, edt_config, manifest, compression_lzma_dictsize, compression_lzma_pb, compression_lzma_lc, compression_lzma_lp, compression_lzma_preset): - if confirm: + if confirm or test: # Confirmed but non-padded images don't make much sense, because # otherwise there's no trailer area for writing the confirmed status. pad = True img = image.Image(version=decode_version(version), header_size=header_size, pad_header=pad_header, pad=pad, confirm=confirm, - align=int(align), slot_size=slot_size, + test=test, align=int(align), slot_size=slot_size, max_sectors=max_sectors, overwrite_only=overwrite_only, endian=endian, load_addr=load_addr, rom_fixed=rom_fixed, erased_val=erased_val, save_enctlv=save_enctlv, From fcf00844f81205e2ac7789ace56148038ffd8521 Mon Sep 17 00:00:00 2001 From: Lukasz Fundakowski Date: Thu, 30 Oct 2025 11:55:28 +0100 Subject: [PATCH 2/5] [nrf fromtree] scripts: Move pytest tests from imgtool/keys to tests directory Moved pytest tests from imgtool/keys to tests directory. Signed-off-by: Lukasz Fundakowski (cherry picked from commit 5b6f0456ad56689e34c31fc7d8035b7c2e56e797) Signed-off-by: Dominik Ermel --- scripts/{imgtool/keys/ecdsa_test.py => tests/keys/test_ecdsa.py} | 0 .../{imgtool/keys/ed25519_test.py => tests/keys/test_ed25519.py} | 0 scripts/{imgtool/keys/rsa_test.py => tests/keys/test_rsa.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename scripts/{imgtool/keys/ecdsa_test.py => tests/keys/test_ecdsa.py} (100%) rename scripts/{imgtool/keys/ed25519_test.py => tests/keys/test_ed25519.py} (100%) rename scripts/{imgtool/keys/rsa_test.py => tests/keys/test_rsa.py} (100%) diff --git a/scripts/imgtool/keys/ecdsa_test.py b/scripts/tests/keys/test_ecdsa.py similarity index 100% rename from scripts/imgtool/keys/ecdsa_test.py rename to scripts/tests/keys/test_ecdsa.py diff --git a/scripts/imgtool/keys/ed25519_test.py b/scripts/tests/keys/test_ed25519.py similarity index 100% rename from scripts/imgtool/keys/ed25519_test.py rename to scripts/tests/keys/test_ed25519.py diff --git a/scripts/imgtool/keys/rsa_test.py b/scripts/tests/keys/test_rsa.py similarity index 100% rename from scripts/imgtool/keys/rsa_test.py rename to scripts/tests/keys/test_rsa.py From 87765d6e8dfb1d715a6d56b1cdadbc20d7c67426 Mon Sep 17 00:00:00 2001 From: Jamie McCrae Date: Tue, 2 Dec 2025 15:40:46 +0000 Subject: [PATCH 3/5] [nrf fromtree] scripts: imgtool: Fix verification with public ed25519 key file Fixes an issue whereby ed25519 public keys were not supported for verifying images which would wrongly give an error about the key type not matching the TLV record Signed-off-by: Jamie McCrae (cherry picked from commit a2ee0a751b45753adf30216b2989865f5b66c29f) Signed-off-by: Dominik Ermel --- scripts/imgtool/image.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/imgtool/image.py b/scripts/imgtool/image.py index bd03ad0999..264120a2d9 100755 --- a/scripts/imgtool/image.py +++ b/scripts/imgtool/image.py @@ -188,6 +188,7 @@ def tlv_sha_to_sha(tlv): keys.RSAPublic : ['256'], # This two are set to 256 for compatibility, the right would be 512 keys.Ed25519 : ['256', '512'], + keys.Ed25519Public : ['256', '512'], keys.X25519 : ['256', '512'] } From 3abb6c9ceea563ff5af3e1248f31e48439019707 Mon Sep 17 00:00:00 2001 From: Petr Ledvina Date: Tue, 3 Feb 2026 08:51:25 +0100 Subject: [PATCH 4/5] [nrf fromtree] imgtool: handle .hex in dumpinfo Signed-off-by: Petr Ledvina (cherry picked from commit 6faf53add89c29cb70c60e760ca057ba2d679c23) Signed-off-by: Dominik Ermel --- scripts/imgtool/dumpinfo.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/imgtool/dumpinfo.py b/scripts/imgtool/dumpinfo.py index 3866c9a104..343ba09a2b 100644 --- a/scripts/imgtool/dumpinfo.py +++ b/scripts/imgtool/dumpinfo.py @@ -22,6 +22,7 @@ import click import yaml +from intelhex import IntelHex from imgtool import image @@ -127,9 +128,13 @@ def dump_imginfo(imgfile, outfile=None, silent=False): trailer = {} key_field_len = None + ext = os.path.splitext(imgfile)[1][1:].lower() try: - with open(imgfile, "rb") as f: - b = f.read() + if ext == image.INTEL_HEX_EXT: + b = IntelHex(imgfile).tobinstr() + else: + with open(imgfile, "rb") as f: + b = f.read() except FileNotFoundError: raise click.UsageError(f"Image file not found ({imgfile})") From 97ff10cb75b0565ab020380bf6a451cf264abc52 Mon Sep 17 00:00:00 2001 From: Simon Frank Date: Fri, 13 Feb 2026 18:10:06 +0100 Subject: [PATCH 5/5] [nrf fromtree] imgtool: dumpinfo -f,--format option Add -f, --format option to support multiple output formats (human, yaml, json). Default yaml for file, default human for stdout - backward compatible. Details: - Human output is unchanged - Refactored dump_imginfo() to separate data reading from formatting - JSON output uses hex strings for binary data (e.g., "e3a333ca...") - YAML and JSON have both "type" (int) and "type_name" (str) for TLVs - Image filename (basename) is included in all output formats - Argument `--silent` is silently ignored. Doesn't make sense for a command that outputs data. i.e. No output on success. This is the same as for other sub commands Signed-off-by: Simon Frank (cherry picked from commit 7448115e8d181212e2872eb85fc4869f095069ff) Signed-off-by: Dominik Ermel --- scripts/imgtool/dumpinfo.py | 203 +++++++++++++++++++++++------------- scripts/imgtool/main.py | 11 +- 2 files changed, 137 insertions(+), 77 deletions(-) diff --git a/scripts/imgtool/dumpinfo.py b/scripts/imgtool/dumpinfo.py index 343ba09a2b..2049f723a2 100644 --- a/scripts/imgtool/dumpinfo.py +++ b/scripts/imgtool/dumpinfo.py @@ -17,8 +17,10 @@ """ Parse and print header, TLV area and trailer information of a signed image. """ +import json import os.path import struct +import sys import click import yaml @@ -75,50 +77,51 @@ def parse_boot_magic(trailer_magic): return magic -def print_in_frame(header_text, content): +def _human_format_frame(header_text, content): sepc = " " header = "#### " + header_text + sepc post_header = "#" * (_LINE_LENGTH - len(header)) - print(header + post_header) - - print("|", sepc * (_LINE_LENGTH - 2), "|", sep="") + lines = [] + lines.append(header + post_header) + lines.append("|" + sepc * (_LINE_LENGTH - 2) + "|") offset = (_LINE_LENGTH - len(content)) // 2 pre = "|" + (sepc * (offset - 1)) post = sepc * (_LINE_LENGTH - len(pre) - len(content) - 1) + "|" - print(pre, content, post, sep="") - print("|", sepc * (_LINE_LENGTH - 2), "|", sep="") - print("#" * _LINE_LENGTH) + lines.append(pre + content + post) + lines.append("|" + sepc * (_LINE_LENGTH - 2) + "|") + lines.append("#" * _LINE_LENGTH) + return "\n".join(lines) -def print_in_row(row_text): +def _human_format_row(row_text): row_text = "#### " + row_text + " " fill = "#" * (_LINE_LENGTH - len(row_text)) - print(row_text + fill) + return row_text + fill -def print_tlv_records(tlv_list): +def _human_format_tlv_records(tlv_list): indent = _LINE_LENGTH // 8 + lines = [] for tlv in tlv_list: - print(" " * indent, "-" * 45) - tlv_type, tlv_length, tlv_data = tlv.keys() + lines.append(" " * indent + " " + "-" * 45) - if tlv[tlv_type] in TLV_TYPES: - print(" " * indent, f"{tlv_type}: {TLV_TYPES[tlv[tlv_type]]} ({hex(tlv[tlv_type])})") - else: - print(" " * indent, "{}: {} ({})".format( - tlv_type, "UNKNOWN", hex(tlv[tlv_type]))) - print(" " * indent, f"{tlv_length}: ", hex(tlv[tlv_length])) - print(" " * indent, f"{tlv_data}: ", end="") + type_name = tlv["type_name"] + type_hex = hex(tlv["type"]) + lines.append(" " * indent + f" type: {type_name} ({type_hex})") + lines.append(" " * indent + f" len: {hex(tlv['len'])}") - for j, data in enumerate(tlv[tlv_data]): - print(f"{data:#04x}", end=" ") - if ((j + 1) % 8 == 0) and ((j + 1) != len(tlv[tlv_data])): - print("\n", end=" " * (indent + 7)) - print() + data_line = " " * indent + " data: " + for j, data in enumerate(tlv["data"]): + data_line += f"{data:#04x} " + if ((j + 1) % 8 == 0) and ((j + 1) != len(tlv["data"])): + lines.append(data_line) + data_line = " " * (indent + 7) + lines.append(data_line) + return "\n".join(lines) -def dump_imginfo(imgfile, outfile=None, silent=False): - """Parse a signed image binary and print/save the available information.""" +def _read_imginfo(imgfile): + """Parse a signed image binary and return the image data structure.""" trailer_magic = None # set to INVALID by default swap_size = 0x99 @@ -126,7 +129,6 @@ def dump_imginfo(imgfile, outfile=None, silent=False): copy_done = 0x99 image_ok = 0x99 trailer = {} - key_field_len = None ext = os.path.splitext(imgfile)[1][1:].lower() try: @@ -174,7 +176,10 @@ def dump_imginfo(imgfile, outfile=None, silent=False): tlv_off += image.TLV_INFO_SIZE tlv_data = b[tlv_off:(tlv_off + tlv_len)] tlv_area["tlvs_prot"].append( - {"type": tlv_type, "len": tlv_len, "data": tlv_data}) + {"type": tlv_type, + "type_name": TLV_TYPES.get(tlv_type, "UNKNOWN"), + "len": tlv_len, + "data": tlv_data}) tlv_off += tlv_len _tlv_head = struct.unpack('HH', b[tlv_off:(tlv_off + image.TLV_INFO_SIZE)]) @@ -192,7 +197,8 @@ def dump_imginfo(imgfile, outfile=None, silent=False): tlv_off += image.TLV_INFO_SIZE tlv_data = b[tlv_off:(tlv_off + tlv_len)] tlv_area["tlvs"].append( - {"type": tlv_type, "len": tlv_len, "data": tlv_data}) + {"type": tlv_type, "type_name": TLV_TYPES.get(tlv_type, "UNKNOWN"), + "len": tlv_len, "data": tlv_data}) tlv_off += tlv_len _img_pad_size = len(b) - tlv_end @@ -241,26 +247,28 @@ def dump_imginfo(imgfile, outfile=None, silent=False): # Estimated value of key_field_len is correct if # BOOT_SWAP_SAVE_ENCTLV is unset key_field_len = image.align_up(16, max_align) * 2 + trailer["key_field_len"] = key_field_len - # Generating output yaml file - if outfile is not None: - imgdata = {"header": header, - "tlv_area": tlv_area, - "trailer": trailer} - with open(outfile, "w") as outf: - # sort_keys - from pyyaml 5.1 - yaml.dump(imgdata, outf, sort_keys=False) + imginfo = { + "filename": os.path.basename(imgfile), + "header": header, + "tlv_area": tlv_area, + "trailer": trailer} - ############################################################################### + return imginfo - if silent: - return - print("Printing content of signed image:", os.path.basename(imgfile), "\n") +def _write_format_human(imginfo, outfile): + filename = imginfo["filename"] + header = imginfo["header"] + tlv_area = imginfo["tlv_area"] + trailer = imginfo["trailer"] + + print("Printing content of signed image:", filename, "\n", file=outfile) # Image header section_name = "Image header (offset: 0x0)" - print_in_row(section_name) + print(_human_format_row(section_name), file=outfile) for key, value in header.items(): if key == "flags": if not value: @@ -276,55 +284,106 @@ def dump_imginfo(imgfile, outfile=None, silent=False): if not isinstance(value, str): value = hex(value) - print(key, ":", " " * (19 - len(key)), value, sep="") - print("#" * _LINE_LENGTH) + print(key, ":", " " * (19 - len(key)), value, sep="", file=outfile) + print("#" * _LINE_LENGTH, file=outfile) # Image payload _sectionoff = header["hdr_size"] frame_header_text = f"Payload (offset: {hex(_sectionoff)})" frame_content = "FW image (size: {} Bytes)".format(hex(header["img_size"])) - print_in_frame(frame_header_text, frame_content) + print(_human_format_frame(frame_header_text, frame_content), file=outfile) # TLV area _sectionoff += header["img_size"] + protected_tlv_size = header["protected_tlv_size"] if protected_tlv_size != 0: # Protected TLV area section_name = f"Protected TLV area (offset: {hex(_sectionoff)})" - print_in_row(section_name) - print("magic: ", hex(tlv_area["tlv_hdr_prot"]["magic"])) - print("area size:", hex(tlv_area["tlv_hdr_prot"]["tlv_tot"])) - print_tlv_records(tlv_area["tlvs_prot"]) - print("#" * _LINE_LENGTH) + print(_human_format_row(section_name), file=outfile) + print("magic: ", hex(tlv_area["tlv_hdr_prot"]["magic"]), file=outfile) + print("area size:", hex(tlv_area["tlv_hdr_prot"]["tlv_tot"]), file=outfile) + print(_human_format_tlv_records(tlv_area["tlvs_prot"]), file=outfile) + print("#" * _LINE_LENGTH, file=outfile) _sectionoff += protected_tlv_size section_name = f"TLV area (offset: {hex(_sectionoff)})" - print_in_row(section_name) - print("magic: ", hex(tlv_area["tlv_hdr"]["magic"])) - print("area size:", hex(tlv_area["tlv_hdr"]["tlv_tot"])) - print_tlv_records(tlv_area["tlvs"]) - print("#" * _LINE_LENGTH) - - if _img_pad_size: + print(_human_format_row(section_name), file=outfile) + print("magic: ", hex(tlv_area["tlv_hdr"]["magic"]), file=outfile) + print("area size:", hex(tlv_area["tlv_hdr"]["tlv_tot"]), file=outfile) + print(_human_format_tlv_records(tlv_area["tlvs"]), file=outfile) + print("#" * _LINE_LENGTH, file=outfile) + + # Check if trailer has data (for image padding and trailer info) + if trailer.get("magic"): + trailer_magic = trailer["magic"] _sectionoff += tlv_area["tlv_hdr"]["tlv_tot"] - _erased_val = b[_sectionoff] - frame_header_text = f"Image padding (offset: {hex(_sectionoff)})" - frame_content = f"padding ({hex(_erased_val)})" - print_in_frame(frame_header_text, frame_content) + # Note: We don't have access to original binary data here, so skip padding details # Image trailer section_name = "Image trailer (offset: unknown)" - print_in_row(section_name) + print(_human_format_row(section_name), file=outfile) notice = "(Note: some fields may not be used, depending on the update strategy)\n" notice = '\n'.join(notice[i:i + _LINE_LENGTH] for i in range(0, len(notice), _LINE_LENGTH)) - print(notice) - print("swap status: (len: unknown)") - print("enc. keys: ", parse_enc(key_field_len)) - print("swap size: ", parse_size(hex(swap_size))) - print("swap_info: ", parse_status(hex(swap_info))) - print("copy_done: ", parse_status(hex(copy_done))) - print("image_ok: ", parse_status(hex(image_ok))) - print("boot magic: ", parse_boot_magic(trailer_magic)) - print() + print(notice, file=outfile) + print("swap status: (len: unknown)", file=outfile) + print("enc. keys: ", parse_enc(trailer.get("key_field_len")), file=outfile) + + # Only print trailer fields if they exist + if "swap_size" in trailer: + print("swap size: ", parse_size(hex(trailer["swap_size"])), file=outfile) + if "swap_info" in trailer: + print("swap_info: ", parse_status(hex(trailer["swap_info"])), file=outfile) + if "copy_done" in trailer: + print("copy_done: ", parse_status(hex(trailer["copy_done"])), file=outfile) + if "image_ok" in trailer: + print("image_ok: ", parse_status(hex(trailer["image_ok"])), file=outfile) + print("boot magic: ", parse_boot_magic(trailer_magic), file=outfile) + print(file=outfile) footer = "End of Image " - print_in_row(footer) + print(_human_format_row(footer), file=outfile) + +def _json_default_serializer(obj): + """Convert non-JSON-serializable objects to JSON-serializable format.""" + if isinstance(obj, (bytes, bytearray)): + return obj.hex() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +def _write_format(imginfo, output_format, out): + """Write image info in the specified format to the output stream.""" + if output_format == 'human': + _write_format_human(imginfo, out) + + elif output_format == 'yaml': + yaml.dump(imginfo, out, sort_keys=False) + + elif output_format == 'json': + json.dump(imginfo, out, indent=2, default=_json_default_serializer) + if out == sys.stdout: + print() # Add newline after JSON output to stdout + else: + raise ValueError(f"Invalid output format: {output_format}") + + +def dump_imginfo(imgfile, outfile=None, output_format=None, silent=False): + """Parse a signed image binary and print/save the available information.""" + + # Note: silent parameter is kept for backward compatibility but is ignored. + # The function's purpose is to output data, so silent doesn't make sense here. + + # Determine output format based on backward compatibility rules + if output_format is None: + if outfile is None: + output_format = 'human' # no --outfile defaults to human-friendly + else: + output_format = 'yaml' # --outfile without --format defaults to yaml + + imginfo = _read_imginfo(imgfile) + + if outfile: + with open(outfile, "w") as out: + _write_format(imginfo, output_format, out) + else: + _write_format(imginfo, output_format, sys.stdout) + diff --git a/scripts/imgtool/main.py b/scripts/imgtool/main.py index 5f102b9ea5..aa85ce6316 100755 --- a/scripts/imgtool/main.py +++ b/scripts/imgtool/main.py @@ -246,15 +246,16 @@ def verify(key, imgfile): @click.argument('imgfile') @click.option('-o', '--outfile', metavar='filename', required=False, - help='Save image information to outfile in YAML format') + help='Save image information to outfile') +@click.option('-f', '--format', 'output_format', + type=click.Choice(['human', 'yaml', 'json']), + help='Output format (human, yaml, json). Default: human for stdout, yaml for file') @click.option('-s', '--silent', default=False, is_flag=True, help='Do not print image information to output') @click.command(help='Print header, TLV area and trailer information ' 'of a signed image') -def dumpinfo(imgfile, outfile, silent): - dump_imginfo(imgfile, outfile, silent) - if not silent: - print("dumpinfo has run successfully") +def dumpinfo(imgfile, outfile, output_format, silent): + dump_imginfo(imgfile, outfile, output_format, silent) def validate_version(ctx, param, value):