Skip to content

Commit 625e1f0

Browse files
authored
Merge pull request #85 from intercreate/fix/#83/update-mcuboot-image-tlv-types
feat: update mcuboot image TLV spec
2 parents bf5787d + 57b621e commit 625e1f0

2 files changed

Lines changed: 204 additions & 8 deletions

File tree

smpclient/mcuboot.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
from enum import IntEnum, IntFlag, unique
1212
from functools import cached_property
1313
from io import BufferedReader, BytesIO
14-
from typing import Dict, Final, List
14+
from typing import Annotated, Any, Dict, Final, List, Union
1515

1616
from intelhex import hex2bin # type: ignore
17+
from pydantic import Field, GetCoreSchemaHandler
1718
from pydantic.dataclasses import dataclass
19+
from pydantic_core import CoreSchema, core_schema
1820

1921
IMAGE_MAGIC: Final = 0x96F3B83D
2022
IMAGE_HEADER_SIZE: Final = 32
@@ -60,12 +62,21 @@ class IMAGE_F(IntFlag):
6062

6163
@unique
6264
class IMAGE_TLV(IntEnum):
63-
"""Image trailer TLV types."""
65+
"""Image trailer TLV types.
66+
67+
Specification: https://docs.mcuboot.com/design.html#image-format
68+
"""
6469

6570
KEYHASH = 0x01
66-
"""hash of the public key"""
71+
"""Hash of the public key"""
72+
PUBKEY = 0x02
73+
"""Public key"""
6774
SHA256 = 0x10
6875
"""SHA256 of image hdr and body"""
76+
SHA384 = 0x11
77+
"""SHA384 of image hdr and body"""
78+
SHA512 = 0x12
79+
"""SHA512 of image hdr and body"""
6980
RSA2048_PSS = 0x20
7081
"""RSA2048 of hash output"""
7182
ECDSA224 = 0x21
@@ -76,6 +87,8 @@ class IMAGE_TLV(IntEnum):
7687
"""RSA3072 of hash output"""
7788
ED25519 = 0x24
7889
"""ED25519 of hash output"""
90+
SIG_PURE = 0x25
91+
"""Signature prepared over full image rather than digest"""
7992
ENC_RSA2048 = 0x30
8093
"""Key encrypted with RSA-OAEP-2048"""
8194
ENC_KW = 0x31
@@ -84,10 +97,69 @@ class IMAGE_TLV(IntEnum):
8497
"""Key encrypted with ECIES-P256"""
8598
ENC_X25519 = 0x33
8699
"""Key encrypted with ECIES-X25519"""
100+
ENC_X25519_SHA512 = 0x34
101+
"""Key exchange using X25519 with SHA512 MAC"""
87102
DEPENDENCY = 0x40
88103
"""Image depends on other image"""
89104
SEC_CNT = 0x50
90-
"""security counter"""
105+
"""Security counter"""
106+
BOOT_RECORD = 0x60
107+
"""Measured boot record"""
108+
DECOMP_SIZE = 0x70
109+
"""Decompressed image size excluding header/TLVs"""
110+
DECOMP_SHA = 0x71
111+
"""Decompressed image hash matching format of compressed slot"""
112+
DECOMP_SIGNATURE = 0x72
113+
"""Decompressed image signature matching compressed format"""
114+
COMP_DEC_SIZE = 0x73
115+
"""Compressed decrypted image size"""
116+
UUID_VID = 0x80
117+
"""Vendor unique identifier"""
118+
UUID_CID = 0x81
119+
"""Device class unique identifier"""
120+
121+
122+
class VendorTLV(int):
123+
"""Vendor-defined TLV type in reserved ranges (0xXXA0-0xXXFE).
124+
125+
Vendor reserved TLVs occupy ranges from 0xXXA0 to 0xXXFE, where XX
126+
represents any upper byte value. Examples include ranges 0x00A0-0x00FF,
127+
0x01A0-0x01FF, and 0x02A0-0x02FF, continuing through 0xFFA0-0xFFFE.
128+
"""
129+
130+
def __new__(cls, value: int) -> 'VendorTLV':
131+
"""Create a new VendorTLV, validating the range."""
132+
lower_byte = value & 0xFF
133+
if not (0xA0 <= lower_byte <= 0xFE):
134+
raise ValueError(
135+
f"VendorTLV 0x{value:02x} must have lower byte in range 0xA0-0xFE, "
136+
f"got 0x{lower_byte:02x}"
137+
)
138+
return int.__new__(cls, value)
139+
140+
@classmethod
141+
def __get_pydantic_core_schema__(
142+
cls, _source_type: Any, _handler: GetCoreSchemaHandler
143+
) -> CoreSchema:
144+
def validate(value: int) -> VendorTLV:
145+
return cls(value)
146+
147+
return core_schema.no_info_after_validator_function(
148+
validate,
149+
core_schema.int_schema(),
150+
)
151+
152+
153+
ImageTLVType = Annotated[Union[IMAGE_TLV, VendorTLV, int], Field(union_mode="left_to_right")]
154+
"""TLV type that accepts standard IMAGE_TLV enums, vendor-defined TLVs, or any integer.
155+
156+
This uses Pydantic's "left to right" union mode to:
157+
1. First try to match against IMAGE_TLV enum values
158+
2. Then try to validate as a VendorTLV (0xXXA0-0xXXFE ranges)
159+
3. Finally accept any integer as a fallback
160+
161+
This ensures backward compatibility and supports future TLV types without validation errors.
162+
"""
91163

92164

93165
@dataclass(frozen=True)
@@ -189,7 +261,7 @@ def load_from(file: BytesIO | BufferedReader) -> 'ImageTLVInfo':
189261
class ImageTLV:
190262
"""A TLV header - type and length."""
191263

192-
type: IMAGE_TLV
264+
type: ImageTLVType
193265
len: int
194266
"""Data length (not including TLV header)."""
195267

@@ -209,7 +281,12 @@ def __post_init__(self) -> None:
209281
raise MCUBootImageError(f"TLV requires length {self.header.len}, got {len(self.value)}")
210282

211283
def __str__(self) -> str:
212-
return f"{self.header.type.name}={self.value.hex()}"
284+
type_name = (
285+
self.header.type.name
286+
if isinstance(self.header.type, IMAGE_TLV)
287+
else f"0x{self.header.type:02x}"
288+
)
289+
return f"{type_name}={self.value.hex()}"
213290

214291

215292
@dataclass(frozen=True)
@@ -221,7 +298,7 @@ class ImageInfo:
221298
tlvs: List[ImageTLVValue]
222299
file: str | None = None
223300

224-
def get_tlv(self, tlv: IMAGE_TLV) -> ImageTLVValue:
301+
def get_tlv(self, tlv: ImageTLVType) -> ImageTLVValue:
225302
"""Get a TLV from the image or raise `TLVNotFound`."""
226303
if tlv in self._map_tlv_type_to_value:
227304
return self._map_tlv_type_to_value[tlv]
@@ -263,7 +340,7 @@ def load_file(path: str) -> 'ImageInfo':
263340
return ImageInfo(file=path, header=image_header, tlv_info=tlv_info, tlvs=tlvs)
264341

265342
@cached_property
266-
def _map_tlv_type_to_value(self) -> Dict[IMAGE_TLV, ImageTLVValue]:
343+
def _map_tlv_type_to_value(self) -> Dict[int, ImageTLVValue]:
267344
return {tlv.header.type: tlv for tlv in self.tlvs}
268345

269346
def __str__(self) -> str:

tests/test_mcuboot_tools.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
IMAGE_TLV_INFO_MAGIC,
1515
ImageHeader,
1616
ImageInfo,
17+
ImageTLV,
18+
ImageTLVType,
19+
ImageTLVValue,
1720
ImageVersion,
21+
VendorTLV,
1822
)
1923

2024

@@ -113,3 +117,118 @@ def test_ImageVersion() -> None:
113117
assert v.revision == 0xFFFF
114118
assert v.build_num == 0xFFFFFFFF
115119
assert str(v) == "1.255.65535-build4294967295"
120+
121+
122+
def test_pubkey_tlv_exists() -> None:
123+
"""Test that PUBKEY (0x02) TLV type exists.
124+
125+
https://github.com/intercreate/smpclient/issues/83
126+
"""
127+
assert IMAGE_TLV.PUBKEY == 0x02
128+
assert IMAGE_TLV.PUBKEY.name == "PUBKEY"
129+
130+
131+
def test_standard_tlv_coercion() -> None:
132+
"""Test that standard TLV values are coerced to IMAGE_TLV enum."""
133+
# PUBKEY (the bug fix!)
134+
tlv = ImageTLV(type=0x02, len=256)
135+
assert isinstance(tlv.type, IMAGE_TLV)
136+
assert tlv.type == IMAGE_TLV.PUBKEY
137+
assert tlv.type.name == "PUBKEY"
138+
139+
# SHA256
140+
tlv = ImageTLV(type=0x10, len=32)
141+
assert isinstance(tlv.type, IMAGE_TLV)
142+
assert tlv.type == IMAGE_TLV.SHA256
143+
144+
# SHA384
145+
tlv = ImageTLV(type=0x11, len=48)
146+
assert isinstance(tlv.type, IMAGE_TLV)
147+
assert tlv.type == IMAGE_TLV.SHA384
148+
149+
150+
def test_vendor_tlv_validation() -> None:
151+
"""Test that vendor TLV ranges are validated correctly."""
152+
# Lower byte 0xA0-0xFE should be valid vendor TLVs
153+
tlv = ImageTLV(type=0xA0, len=16)
154+
assert isinstance(tlv.type, VendorTLV)
155+
assert tlv.type == 0xA0
156+
157+
tlv = ImageTLV(type=0xFE, len=8)
158+
assert isinstance(tlv.type, VendorTLV)
159+
assert tlv.type == 0xFE
160+
161+
# Multi-byte vendor TLVs
162+
tlv = ImageTLV(type=0x01A0, len=16)
163+
assert isinstance(tlv.type, VendorTLV)
164+
assert tlv.type == 0x01A0
165+
166+
tlv = ImageTLV(type=0xFFFE, len=4)
167+
assert isinstance(tlv.type, VendorTLV)
168+
assert tlv.type == 0xFFFE
169+
170+
171+
def test_unknown_tlv_fallback() -> None:
172+
"""Test that unknown TLV types fall back to int without error."""
173+
# This should not raise a validation error
174+
tlv = ImageTLV(type=0x99, len=8)
175+
assert isinstance(tlv.type, int)
176+
assert tlv.type == 0x99
177+
178+
# Another unknown type
179+
tlv = ImageTLV(type=0x05, len=4)
180+
assert isinstance(tlv.type, int)
181+
assert tlv.type == 0x05
182+
183+
184+
def test_tlv_type_union_order() -> None:
185+
"""Test that union resolution follows left-to-right order."""
186+
from pydantic import TypeAdapter
187+
188+
adapter: TypeAdapter[ImageTLVType] = TypeAdapter(ImageTLVType)
189+
190+
# Standard TLV should match IMAGE_TLV first
191+
result = adapter.validate_python(0x02)
192+
assert isinstance(result, IMAGE_TLV)
193+
assert result == IMAGE_TLV.PUBKEY
194+
195+
# Vendor TLV should validate
196+
result = adapter.validate_python(0xA0)
197+
assert isinstance(result, int)
198+
assert result == 0xA0
199+
200+
# Unknown TLV should fallback to int
201+
result = adapter.validate_python(0x99)
202+
assert isinstance(result, int)
203+
assert result == 0x99
204+
205+
206+
def test_tlv_value_str_standard() -> None:
207+
"""Test __str__ with standard IMAGE_TLV enum types."""
208+
# PUBKEY
209+
tlv_header = ImageTLV(type=0x02, len=4)
210+
tlv_value = ImageTLVValue(header=tlv_header, value=b"\x00\x01\x02\x03")
211+
assert str(tlv_value) == "PUBKEY=00010203"
212+
213+
# SHA256
214+
tlv_header = ImageTLV(type=0x10, len=4)
215+
tlv_value = ImageTLVValue(header=tlv_header, value=b"\xAA\xBB\xCC\xDD")
216+
assert str(tlv_value) == "SHA256=aabbccdd"
217+
218+
219+
def test_tlv_value_str_vendor() -> None:
220+
"""Test __str__ with vendor TLV types (should show hex)."""
221+
tlv_header = ImageTLV(type=0xA0, len=4)
222+
tlv_value = ImageTLVValue(header=tlv_header, value=b"\xFF\xFF\xFF\xFF")
223+
assert str(tlv_value) == "0xa0=ffffffff"
224+
225+
tlv_header = ImageTLV(type=0xFE, len=2)
226+
tlv_value = ImageTLVValue(header=tlv_header, value=b"\x12\x34")
227+
assert str(tlv_value) == "0xfe=1234"
228+
229+
230+
def test_tlv_value_str_unknown() -> None:
231+
"""Test __str__ with unknown TLV types (should show hex)."""
232+
tlv_header = ImageTLV(type=0x99, len=4)
233+
tlv_value = ImageTLVValue(header=tlv_header, value=b"\xDE\xAD\xBE\xEF")
234+
assert str(tlv_value) == "0x99=deadbeef"

0 commit comments

Comments
 (0)