STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
firmware_digest.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""The digest a board reports for its own firmware, read out of a built image.
3
4An ESP32 app image built with a hash appended carries its SHA-256 as the last
532 bytes of the image. `esp_partition_get_sha256()` -- what `firmware_hash.h`
6calls, and so what every board puts in its BoardHeartbeatPacket -- returns
7*those bytes*, after recomputing them once to check they are right. It does not
8hash the file. So the value to compare a board against is the appended digest,
9NOT `sha256(firmware.bin)`: hashing the whole file folds the digest into its own
10input and can never match a board. See FIRMWARE_HASH_VERIFICATION.md.
11
12Usage:
13 firmware_digest.py <firmware.bin>
14 firmware_digest.py <firmware.bin> --verbose
15 firmware_digest.py <firmware.bin> --record --board PT_Hotfire --env <pio env>
16"""
17
18from __future__ import annotations
19
20import argparse
21import hashlib
22import json
23import os
24import struct
25import subprocess
26import sys
27from pathlib import Path
28
29IMAGE_MAGIC = 0xE9
30HEADER_LEN = 24 # esp_image_header_t
31SEGMENT_HEADER_LEN = 8 # esp_image_segment_header_t: load_addr + data_len
32HASH_LEN = 32
33
34
35class ImageError(Exception):
36 pass
37
38
39def _parse_image(data: bytes) -> dict:
40 """Walk the image the way the ROM loader does, to find where it really ends.
41
42 We do not trust the file's length: a .bin can carry padding past the end of
43 the image, and then the appended digest is not the last 32 bytes of the file.
44 """
45 if len(data) < HEADER_LEN:
46 raise ImageError(f"too short to be an image ({len(data)} bytes)")
47 if data[0] != IMAGE_MAGIC:
48 raise ImageError(f"bad magic {data[0]:#04x}, expected {IMAGE_MAGIC:#04x}")
49
50 segment_count = data[1]
51 hash_appended = bool(data[23])
52
53 off = HEADER_LEN
54 for i in range(segment_count):
55 if off + SEGMENT_HEADER_LEN > len(data):
56 raise ImageError(f"segment {i} header runs past end of file")
57 _load_addr, data_len = struct.unpack_from("<II", data, off)
58 off += SEGMENT_HEADER_LEN + data_len
59 if off > len(data):
60 raise ImageError(f"segment {i} data runs past end of file")
61
62 # One checksum byte, positioned so the image is a multiple of 16 bytes with
63 # the checksum last.
64 off += 1
65 off += (16 - (off % 16)) % 16
66 if hash_appended:
67 off += HASH_LEN
68 if off > len(data):
69 raise ImageError(f"image length {off} exceeds file length {len(data)}")
70
71 return {
72 "segment_count": segment_count,
73 "hash_appended": hash_appended,
74 "image_len": off,
75 "trailing_bytes": len(data) - off,
76 }
77
78
79def image_digest(path: str | os.PathLike) -> tuple[bytes, dict]:
80 """Return (digest, info) -- the 32 bytes a board running this image reports."""
81 data = Path(path).read_bytes()
82 info = _parse_image(data)
83 end = info["image_len"]
84
85 if info["hash_appended"]:
86 digest = data[end - HASH_LEN : end]
87 # The same check IDF makes before handing the digest back. If this fails
88 # the board would report ESP_ERR_IMAGE_INVALID and never send a digest,
89 # so refusing here is the honest answer rather than a plausible hash.
90 recomputed = hashlib.sha256(data[: end - HASH_LEN]).digest()
91 if digest != recomputed:
92 raise ImageError(
93 "appended digest does not match the image content it covers "
94 f"(appended {digest.hex()}, recomputed {recomputed.hex()}); "
95 "the image is corrupt or the format has changed"
96 )
97 else:
98 # No appended hash: IDF hashes the whole image instead.
99 digest = hashlib.sha256(data[:end]).digest()
100
101 info["file_len"] = len(data)
102 info["file_sha256"] = hashlib.sha256(data).hexdigest()
103 return digest, info
104
105
106def _git(*args: str, cwd: str | None = None) -> str:
107 return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip()
108
109
110def _record(path: str, digest: bytes, info: dict, board: str, pio_env: str) -> dict:
111 repo = Path(__file__).resolve().parents[2]
112 rec = {
113 "image_digest": digest.hex(),
114 "file_sha256": info["file_sha256"],
115 "board": board,
116 "pio_env": pio_env,
117 "image_len": info["image_len"],
118 "commits": [],
119 }
120 try:
121 rec["commits"] = [_git("rev-parse", "HEAD", cwd=str(repo))]
122 rec["first_seen_commit_at"] = _git(
123 "log", "-1", "--format=%cI", "HEAD", cwd=str(repo)
124 )
125 rec["subject"] = _git("log", "-1", "--format=%s", "HEAD", cwd=str(repo))
126 except (subprocess.CalledProcessError, OSError) as exc:
127 rec["git_error"] = str(exc)
128 for key, env in (
129 ("run_id", "GITHUB_RUN_ID"),
130 ("run_attempt", "GITHUB_RUN_ATTEMPT"),
131 ):
132 if os.environ.get(env):
133 rec[key] = os.environ[env]
134 if os.environ.get("GITHUB_REPOSITORY") and os.environ.get("GITHUB_RUN_ID"):
135 rec["run_url"] = (
136 f"https://github.com/{os.environ['GITHUB_REPOSITORY']}"
137 f"/actions/runs/{os.environ['GITHUB_RUN_ID']}"
138 )
139 rec["built_by"] = "ci" if os.environ.get("GITHUB_ACTIONS") else "local"
140 # A toolchain bump changes every digest with no source change. Without these
141 # fields the registry just looks corrupt when that happens.
142 # esptool assembles the .bin from the ELF, so it belongs in the provenance:
143 # when a digest disagreed across machines it was the first thing worth ruling
144 # out, and it was not recorded. "toolchains" is plural because a board pulls
145 # both the xtensa and riscv toolchains and naming one was misleading.
146 for key, env in (
147 ("platform", "STAR_PIO_PLATFORM"),
148 ("framework", "STAR_PIO_FRAMEWORK"),
149 ("toolchains", "STAR_PIO_TOOLCHAINS"),
150 ("esptool", "STAR_PIO_ESPTOOL"),
151 ):
152 if os.environ.get(env):
153 rec[key] = os.environ[env]
154 return rec
155
156
157def main() -> int:
158 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
159 ap.add_argument("image", help="path to firmware.bin")
160 ap.add_argument("--verbose", "-v", action="store_true")
161 ap.add_argument(
162 "--record", action="store_true", help="emit a registry record as JSON"
163 )
164 ap.add_argument("--board", default="", help="board name for --record")
165 ap.add_argument(
166 "--env", dest="pio_env", default="", help="PlatformIO env for --record"
167 )
168 args = ap.parse_args()
169
170 try:
171 digest, info = image_digest(args.image)
172 except (ImageError, OSError) as exc:
173 print(f"error: {args.image}: {exc}", file=sys.stderr)
174 return 1
175
176 if args.record:
177 if not args.board:
178 print("error: --record needs --board", file=sys.stderr)
179 return 2
180 json.dump(
181 _record(args.image, digest, info, args.board, args.pio_env),
182 sys.stdout,
183 indent=2,
184 sort_keys=True,
185 )
186 print()
187 return 0
188
189 if args.verbose:
190 print(f"image {args.image}")
191 print(f"segments {info['segment_count']}")
192 print(f"hash_appended {info['hash_appended']}")
193 print(f"image_len {info['image_len']}")
194 print(f"file_len {info['file_len']}")
195 print(f"trailing_bytes {info['trailing_bytes']}")
196 print(f"file_sha256 {info['file_sha256']} <- NOT what a board reports")
197 print(f"image_digest {digest.hex()} <- what a board reports")
198 else:
199 print(digest.hex())
200 return 0
201
202
203if __name__ == "__main__":
204 sys.exit(main())
tuple[bytes, dict] image_digest(str|os.PathLike path)
dict _parse_image(bytes data)
str _git(*str args, str|None cwd=None)
dict _record(str path, bytes digest, dict info, str board, str pio_env)
int main()