Skip to content

Commit 92ace91

Browse files
committed
Introduce new wolfboot image inspection scripts
1 parent c6776bc commit 92ace91

File tree

2 files changed

+383
-0
lines changed

2 files changed

+383
-0
lines changed

tools/scripts/image-peek.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Usage:
4+
# usage: image-peek.py [-h] [--header-size HEADER_SIZE] [--dump-payload OUT] [--verify-hash] [--verify-sig PUBKEY] [--alg {ecdsa-p256,ed25519}] image
5+
#
6+
# Example:
7+
# ./tools/scripts/image-peek.py ./test_v1_signed.bin --verify-sig ./keystore_spki.der --alg ecdsa-p256
8+
9+
import argparse, struct, hashlib, sys, datetime
10+
from pathlib import Path
11+
12+
TYPE_NAMES = {
13+
0x0001: "version",
14+
0x0002: "timestamp",
15+
0x0003: "hash",
16+
0x0004: "attr",
17+
0x0010: "pubkey_hint",
18+
0x0020: "signature",
19+
}
20+
21+
def read_file(path: Path) -> bytes:
22+
return path.read_bytes()
23+
24+
def parse_header(data: bytes, header_size: int = 0x100):
25+
if len(data) < 8:
26+
raise ValueError("Input too small to contain header")
27+
magic = data[0:4]
28+
size_le = struct.unpack("<I", data[4:8])[0]
29+
off = 8
30+
tlvs = []
31+
while off < header_size:
32+
while off < header_size and data[off] == 0xFF:
33+
off += 1
34+
if off + 4 > header_size:
35+
break
36+
t = struct.unpack("<H", data[off:off+2])[0]
37+
l = struct.unpack("<H", data[off+2:off+4])[0]
38+
off += 4
39+
if off + l > header_size:
40+
break
41+
v = data[off:off+l]
42+
off += l
43+
tlvs.append((t, l, v))
44+
return {"magic": magic, "size": size_le, "header_size": header_size, "tlvs": tlvs}
45+
46+
def tlv_dict(tlvs):
47+
d = {}
48+
for (t, l, v) in tlvs:
49+
d.setdefault(t, []).append((l, v))
50+
return d
51+
52+
# add this helper near the top-level functions, e.g., after tlv_dict()
53+
def find_tlv(data: bytes, header_size: int, ttype: int):
54+
"""
55+
Scan the header TLV area and return (value_offset, value_len, tlv_start_offset)
56+
for the first TLV matching 'ttype'. Returns None if not found.
57+
"""
58+
off = 8 # skip magic(4) + size(4)
59+
while off + 4 <= header_size:
60+
# skip padding bytes 0xFF
61+
while off < header_size and data[off] == 0xFF:
62+
off += 1
63+
if off + 4 > header_size:
64+
break
65+
t = int.from_bytes(data[off:off+2], "little")
66+
l = int.from_bytes(data[off+2:off+4], "little")
67+
tlv_hdr = off
68+
off += 4
69+
if off + l > header_size:
70+
break
71+
if t == ttype:
72+
return (off, l, tlv_hdr) # value starts at 'off'
73+
off += l
74+
return None
75+
76+
def decode_timestamp(v: bytes):
77+
ts = struct.unpack("<Q", v)[0]
78+
try:
79+
utc = datetime.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S UTC")
80+
except Exception:
81+
utc = "out-of-range"
82+
return ts, utc
83+
84+
def hash_name_for_len(n: int):
85+
if n == 32: return "sha256"
86+
if n == 48: return "sha384"
87+
if n == 64: return "sha512"
88+
return None
89+
90+
def compute_hash(payload: bytes, name: str):
91+
import hashlib
92+
h = hashlib.new(name)
93+
h.update(payload)
94+
return h.digest()
95+
96+
def try_load_public_key(pubkey_path: Path):
97+
try:
98+
from cryptography.hazmat.primitives import serialization
99+
data = read_file(pubkey_path)
100+
try:
101+
key = serialization.load_pem_public_key(data)
102+
return key
103+
except ValueError:
104+
key = serialization.load_der_public_key(data)
105+
return key
106+
except Exception as e:
107+
return e
108+
109+
def verify_signature(pubkey, alg: str, firmware_hash: bytes, signature: bytes):
110+
try:
111+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, utils
112+
from cryptography.hazmat.primitives import hashes
113+
from cryptography.exceptions import InvalidSignature
114+
except Exception as e:
115+
return False, f"cryptography not available: {e}"
116+
117+
if alg == "ecdsa-p256":
118+
if not hasattr(pubkey, "verify"):
119+
return False, "Public key object is not ECDSA-capable"
120+
if len(signature) != 64:
121+
return False, f"Expected 64-byte r||s, got {len(signature)} bytes"
122+
r = int.from_bytes(signature[:32], "big")
123+
s = int.from_bytes(signature[32:], "big")
124+
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
125+
sig_der = encode_dss_signature(r, s)
126+
hash_algo = {32: hashes.SHA256(), 48: hashes.SHA384(), 64: hashes.SHA512()}.get(len(firmware_hash))
127+
if hash_algo is None:
128+
return False, f"Unsupported hash length {len(firmware_hash)}"
129+
try:
130+
pubkey.verify(sig_der, firmware_hash, ec.ECDSA(utils.Prehashed(hash_algo)))
131+
return True, "Signature OK (ECDSA)"
132+
except InvalidSignature:
133+
return False, "Invalid signature (ECDSA)"
134+
except Exception as e:
135+
return False, f"ECDSA verify error: {e}"
136+
137+
if alg == "ed25519":
138+
try:
139+
if not hasattr(pubkey, "verify"):
140+
return False, "Public key object is not Ed25519-capable"
141+
pubkey.verify(signature, firmware_hash)
142+
return True, "Signature OK (Ed25519 over stored digest)"
143+
except Exception as e:
144+
return False, f"Ed25519 verify error: {e}"
145+
146+
return False, f"Unknown alg '{alg}'"
147+
148+
def main():
149+
ap = argparse.ArgumentParser(description="wolfBoot image parser/validator")
150+
ap.add_argument("image", help="Signed image file")
151+
ap.add_argument("--header-size", type=lambda x: int(x, 0), default="0x100", help="Header size (default 0x100)")
152+
ap.add_argument("--dump-payload", metavar="OUT", help="Write payload to this file")
153+
ap.add_argument("--verify-hash", action="store_true", help="Compute and compare payload hash against the header")
154+
ap.add_argument("--verify-sig", metavar="PUBKEY", help="Verify signature using a PEM/DER public key")
155+
ap.add_argument("--alg", choices=["ecdsa-p256", "ed25519"], help="Signature algorithm (try to infer if omitted)")
156+
args = ap.parse_args()
157+
158+
img_path = Path(args.image)
159+
data = read_file(img_path)
160+
161+
hdr = parse_header(data, header_size=args.header_size)
162+
magic = hdr["magic"]; size = hdr["size"]; header_size = hdr["header_size"]; tlist = hdr["tlvs"]
163+
d = tlv_dict(tlist)
164+
165+
print(f"Magic: {magic.decode('ascii', 'replace')} (raw: {magic.hex()})")
166+
print(f"Payload size: {size} (0x{size:08X})")
167+
print(f"Header size: {header_size} (0x{header_size:X})")
168+
169+
version = d.get(0x0001, [(None, None)])[0][1]
170+
if version is not None:
171+
print(f"Version: {struct.unpack('<I', version)[0]}")
172+
if 0x0002 in d:
173+
ts_val = d[0x0002][0][1]
174+
ts, utc = decode_timestamp(ts_val)
175+
print(f"Timestamp: {ts} ({utc})")
176+
hash_bytes = d.get(0x0003, [(None, None)])[0][1]
177+
if hash_bytes is not None:
178+
print(f"Hash ({len(hash_bytes)} bytes): {hash_bytes.hex()}")
179+
if 0x0010 in d:
180+
hint = d[0x0010][0][1].hex()
181+
print(f"Pubkey hint: {hint}")
182+
sig = d.get(0x0020, [(None, None)])[0][1]
183+
if sig is not None:
184+
print(f"Signature ({len(sig)} bytes): {sig[:8].hex()}...{sig[-8:].hex()}")
185+
186+
if len(data) < header_size + size:
187+
print(f"[WARN] File shorter ({len(data)} bytes) than header+payload ({header_size+size}). Hash/signature verification may fail.")
188+
payload = data[header_size : header_size + size]
189+
190+
if args.dump_payload:
191+
out = Path(args.dump_payload)
192+
out.write_bytes(payload)
193+
print(f"Wrote payload to: {out}")
194+
195+
if args.verify_hash:
196+
if hash_bytes is None:
197+
print("[HASH] No hash TLV found (type 0x0003)")
198+
else:
199+
# locate the actual SHA TLV and compute header_prefix || payload
200+
sha_info = find_tlv(data, header_size, 0x0003)
201+
if sha_info is None:
202+
print("[HASH] Could not locate SHA TLV in header")
203+
else:
204+
sha_val_off, sha_len, sha_tlv_hdr = sha_info
205+
# The header portion includes everything from start of image up to (but not including) Type+Len
206+
header_prefix_end = sha_tlv_hdr # exclude Type(2)+Len(2)
207+
header_prefix = data[0:header_prefix_end]
208+
209+
# Payload is the declared 'size' bytes after the header
210+
payload = data[header_size: header_size + size]
211+
212+
# pick hash by length
213+
hname = hash_name_for_len(sha_len)
214+
if not hname:
215+
print(f"[HASH] Unsupported hash length {sha_len}")
216+
else:
217+
import hashlib
218+
h = hashlib.new(hname)
219+
h.update(header_prefix)
220+
h.update(payload)
221+
calc = h.digest()
222+
ok = (calc == hash_bytes)
223+
print(f"[HASH] Algorithm: {hname} -> {'OK' if ok else 'MISMATCH'}")
224+
if not ok:
225+
print(f"[HASH] expected: {hash_bytes.hex()}")
226+
print(f"[HASH] computed: {calc.hex()}")
227+
228+
229+
if args.verify_sig:
230+
if sig is None:
231+
print("[SIG] No signature TLV found (type 0x0020)")
232+
elif hash_bytes is None:
233+
print("[SIG] Cannot verify without hash TLV (type 0x0003)")
234+
else:
235+
pubkey = try_load_public_key(Path(args.verify_sig))
236+
if isinstance(pubkey, Exception):
237+
print(f"[SIG] Failed to load public key: {pubkey}")
238+
else:
239+
alg = args.alg
240+
if not alg:
241+
if len(sig) == 64 and len(hash_bytes) in (32,48,64):
242+
alg = "ecdsa-p256"
243+
else:
244+
print(f"[SIG] Cannot infer algorithm (sig={len(sig)} bytes, hash={len(hash_bytes) if hash_bytes else 0})")
245+
alg = "ecdsa-p256"
246+
ok, msg = verify_signature(pubkey, alg, hash_bytes, sig)
247+
print(f"[SIG] {msg} (alg={alg})")
248+
249+
if __name__ == '__main__':
250+
main()
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
# Convert wolfBoot raw/public-key container to standard SPKI DER/PEM, next to input.
3+
# Usage:
4+
#
5+
# ./tools/scripts/wolfboot-der-to-spki.py ./tools/keytools/keystore.der
6+
#
7+
# Optional:
8+
# --curve p256|p384|p521 (only needed if auto-detect by length is not possible)
9+
#
10+
# Example (from [WOLFBOOT_ROOT]):
11+
# ./tools/scripts/wolfboot-der-to-spki.py ./tools/keytools/keystore.der
12+
#
13+
import argparse
14+
import sys
15+
from pathlib import Path
16+
17+
def main():
18+
ap = argparse.ArgumentParser(
19+
description="Convert a wolfBoot public key file to SPKI DER/PEM next to the input. "
20+
"Understands SPKI DER, raw X||Y (64/96/132), SEC1 0x04||X||Y (65/97/133), "
21+
"and wolfBoot 16+X||Y containers (80/112/148)."
22+
)
23+
ap.add_argument("input", help="Path to input public key file")
24+
ap.add_argument("--curve", choices=["p256", "p384", "p521"], default=None,
25+
help="Curve override if auto-detect by size is not possible")
26+
args = ap.parse_args()
27+
28+
in_path = Path(args.input).resolve()
29+
if not in_path.is_file():
30+
print("ERROR: input path does not exist or is not a file:", in_path, file=sys.stderr)
31+
sys.exit(2)
32+
raw = in_path.read_bytes()
33+
ln = len(raw)
34+
35+
# Try SPKI DER first
36+
key_obj = None
37+
try:
38+
from cryptography.hazmat.primitives import serialization
39+
key_obj = serialization.load_der_public_key(raw)
40+
# Success: already SPKI DER
41+
except Exception:
42+
key_obj = None
43+
44+
if key_obj is None:
45+
# Not SPKI DER; normalize into SEC1 uncompressed, then import with curve
46+
# Cases:
47+
# 1) raw X||Y (64/96/132)
48+
# 2) SEC1 0x04||X||Y (65/97/133)
49+
# 3) wolfBoot 16+X||Y (80/112/148)
50+
data = raw
51+
is_sec1 = False
52+
53+
# Case 2: SEC1 uncompressed (leading 0x04, lengths 65/97/133)
54+
if ln in (65, 97, 133) and raw[0] == 0x04:
55+
sec1 = raw
56+
is_sec1 = True
57+
xy_len = ln - 1
58+
# Case 3: wolfBoot container 16+X||Y
59+
elif ln in (80, 112, 148):
60+
# Strip the first 16 bytes, keep last 64/96/132
61+
data = raw[16:]
62+
if len(data) not in (64, 96, 132):
63+
print("ERROR: Unexpected container size after stripping 16 bytes:", len(data), file=sys.stderr)
64+
sys.exit(3)
65+
sec1 = b"\x04" + data
66+
is_sec1 = True
67+
xy_len = len(data)
68+
# Case 1: raw X||Y
69+
elif ln in (64, 96, 132):
70+
sec1 = b"\x04" + raw
71+
is_sec1 = True
72+
xy_len = ln
73+
else:
74+
print("ERROR: Unrecognized input size:", ln, file=sys.stderr)
75+
print(" Expected one of: SPKI DER, 64/96/132 (X||Y), 65/97/133 (SEC1), 80/112/148 (16+X||Y).", file=sys.stderr)
76+
sys.exit(3)
77+
78+
# Pick curve by X||Y size if not specified
79+
curve = args.curve
80+
if curve is None:
81+
if xy_len == 64:
82+
curve = "p256"
83+
elif xy_len == 96:
84+
curve = "p384"
85+
elif xy_len == 132:
86+
curve = "p521"
87+
else:
88+
print("ERROR: Cannot infer curve from length:", xy_len, file=sys.stderr)
89+
sys.exit(4)
90+
91+
from cryptography.hazmat.primitives.asymmetric import ec
92+
if curve == "p256":
93+
crv = ec.SECP256R1()
94+
elif curve == "p384":
95+
crv = ec.SECP384R1()
96+
else:
97+
crv = ec.SECP521R1()
98+
99+
try:
100+
key_obj = ec.EllipticCurvePublicKey.from_encoded_point(crv, sec1)
101+
except Exception as e:
102+
print("ERROR: cannot wrap/parse key as SEC1/SPKI:", e, file=sys.stderr)
103+
sys.exit(5)
104+
105+
# Write SPKI next to input
106+
out_der = in_path.with_name(in_path.stem + "_spki.der")
107+
out_pem = in_path.with_name(in_path.stem + "_spki.pem")
108+
109+
from cryptography.hazmat.primitives import serialization
110+
der = key_obj.public_bytes(
111+
serialization.Encoding.DER,
112+
serialization.PublicFormat.SubjectPublicKeyInfo
113+
)
114+
pem = key_obj.public_bytes(
115+
serialization.Encoding.PEM,
116+
serialization.PublicFormat.SubjectPublicKeyInfo
117+
)
118+
out_der.write_bytes(der)
119+
out_pem.write_bytes(pem)
120+
121+
# Print SPKI SHA-256 for pubkey-hint comparison
122+
try:
123+
import hashlib, binascii
124+
h = hashlib.sha256(der).digest()
125+
print("Wrote:", out_der)
126+
print("Wrote:", out_pem)
127+
print("SPKI SHA-256 (hex):", binascii.hexlify(h).decode("ascii"))
128+
except Exception:
129+
print("Wrote:", out_der)
130+
print("Wrote:", out_pem)
131+
132+
if __name__ == "__main__":
133+
main()

0 commit comments

Comments
 (0)