Skip to content

Commit d53bad3

Browse files
committed
imgtool: formalize public-key-only PEM support
imgtool's keys.load() silently falls through to load_pem_public_key when the PEM contains no private material. As a result, `getpub`, `getpubhash`, and `verify` have long accepted public-key-only PEMs, which the Zephyr bootloader build relies on. This was neither tested nor documented, leaving the capability exposed to silent regression. Add parametrized tests covering getpub and getpubhash against pub-only PEMs for all supported key types and encodings, asserting byte equivalence with the private-key path. Replace the AttributeError raised when `imgtool sign` is invoked with a pub-only PEM with a descriptive click.UsageError, and add a regression test. This matters for dev/prod key-split workflows where the signing private key is held only by a release team. Update docs/imgtool.md, docs/signed_images.md, and docs/readme-zephyr.md to state that the bootloader build accepts pub-only PEMs, and that `imgtool sign` itself still requires the private key. Signed-off-by: JP Hutchins <jp@intercreate.io>
1 parent 95a6e38 commit d53bad3

11 files changed

Lines changed: 293 additions & 24 deletions

File tree

docs/imgtool.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@ the key file.
4141

4242
./scripts/imgtool.py getpub -k filename.pem
4343

44-
will extract the public key from the given private key file, and
45-
output it as a C data structure. You can replace or insert this code
46-
into the key file. However, when the `MCUBOOT_HW_KEY` config option is
47-
enabled, this last step is unnecessary and can be skipped.
44+
will extract the public key from the given key file, and output it as
45+
a C data structure. The input may be a keypair PEM (containing both
46+
private and public material) or a public-key-only PEM: `getpub`
47+
dispatches on the key format and requires no private key material.
48+
This matters for production workflows where the signing private key
49+
is held only by a release team, and the bootloader build consumes an
50+
exported public-key PEM. You can replace or insert the emitted code
51+
into the key file. However, when the `MCUBOOT_HW_KEY` config option
52+
is enabled, this last step is unnecessary and can be skipped.
4853

4954
## [Signing images](#signing-images)
5055

docs/readme-zephyr.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ the public key in a format usable by the C compiler.
156156
The generated public key is saved in `build/zephyr/autogen-pubkey.h`, which is included
157157
by the `boot/zephyr/keys.c`.
158158

159+
``CONFIG_BOOT_SIGNATURE_KEY_FILE`` accepts either a keypair PEM or a
160+
public-key-only PEM: only the public key is consumed during the build.
161+
This enables production flows in which the signing private key is held
162+
by a release team and only an exported public key is provided to
163+
bootloader builders. Signing images (`imgtool sign`) requires the
164+
private key.
165+
159166
Currently, the Zephyr RTOS port limits its support to one keypair at the time,
160167
although MCUboot's key management infrastructure supports multiple keypairs.
161168

docs/signed_images.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ For ECDSA256 these commands are similar.
5757
openssl ecparam -name prime256v1 -genkey -noout -out image_sign.pem
5858
openssl ec -in image_sign.pem -pubout -outform DER -out image_sign_pub.der
5959

60+
Note that the bootloader build does not require the private key: the
61+
exported public-key PEM (produced above, or via
62+
`imgtool getpub -k image_sign.pem -e pem`) is itself a valid input to
63+
`imgtool getpub`, and can be used as the
64+
`CONFIG_BOOT_SIGNATURE_KEY_FILE` setting for a Zephyr-based bootloader
65+
build. This supports a development workflow
66+
in which the production signing team releases only the public key,
67+
and the private key is never present on developer workstations.
68+
Running `imgtool sign` requires the private key.
69+
6070
## Creating a key package
6171

6272
xxd -i image_sign_pub.der image_sign_pub.c.import

scripts/imgtool/keys/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@
3131

3232
from .ecdsa import ECDSA256P1, ECDSA384P1, ECDSA256P1Public, ECDSA384P1Public, ECDSAUsageError
3333
from .ed25519 import Ed25519, Ed25519Public, Ed25519UsageError
34+
from .general import DigestSigner, PayloadSigner
3435
from .rsa import RSA, RSA_KEY_SIZES, RSAPublic, RSAUsageError
3536
from .x25519 import X25519, X25519Public, X25519UsageError
3637

3738
__all__ = [
39+
"DigestSigner",
3840
"ECDSA256P1",
3941
"ECDSA384P1",
4042
"ECDSA256P1Public",
@@ -43,6 +45,7 @@
4345
"Ed25519",
4446
"Ed25519Public",
4547
"Ed25519UsageError",
48+
"PayloadSigner",
4649
"RSA",
4750
"RSA_KEY_SIZES",
4851
"RSAPublic",

scripts/imgtool/keys/ecdsa.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
"""
44

55
# SPDX-License-Identifier: Apache-2.0
6+
7+
from __future__ import annotations
8+
69
import os.path
710

811
from cryptography.hazmat.backends import default_backend
912
from cryptography.hazmat.primitives import serialization
1013
from cryptography.hazmat.primitives.asymmetric import ec
1114
from cryptography.hazmat.primitives.hashes import SHA256, SHA384
1215

13-
from .general import KeyClass
16+
from .general import KeyClass, PayloadSigner, override
1417
from .privatebytes import PrivateBytesMixin
1518

1619

@@ -181,7 +184,7 @@ def verify(self, signature, payload):
181184
signature_algorithm=ec.ECDSA(SHA256()))
182185

183186

184-
class ECDSA256P1(ECDSAPrivateKey, ECDSA256P1Public):
187+
class ECDSA256P1(ECDSAPrivateKey, ECDSA256P1Public, PayloadSigner):
185188
"""
186189
Wrapper around an ECDSA (p256) private key.
187190
"""
@@ -191,7 +194,7 @@ def __init__(self, key):
191194
self.pad_sig = False
192195

193196
@staticmethod
194-
def generate():
197+
def generate() -> ECDSA256P1:
195198
pk = ec.generate_private_key(
196199
ec.SECP256R1(),
197200
backend=default_backend())
@@ -203,7 +206,8 @@ def raw_sign(self, payload):
203206
data=payload,
204207
signature_algorithm=ec.ECDSA(SHA256()))
205208

206-
def sign(self, payload):
209+
@override
210+
def sign(self, payload: bytes) -> bytes:
207211
sig = self.raw_sign(payload)
208212
if self.pad_sig:
209213
# To make fixed length, pad with one or two zeros.
@@ -254,7 +258,7 @@ def verify(self, signature, payload):
254258
signature_algorithm=ec.ECDSA(SHA384()))
255259

256260

257-
class ECDSA384P1(ECDSAPrivateKey, ECDSA384P1Public):
261+
class ECDSA384P1(ECDSAPrivateKey, ECDSA384P1Public, PayloadSigner):
258262
"""
259263
Wrapper around an ECDSA (p384) private key.
260264
"""
@@ -266,7 +270,7 @@ def __init__(self, key):
266270
self.pad_sig = False
267271

268272
@staticmethod
269-
def generate():
273+
def generate() -> ECDSA384P1:
270274
pk = ec.generate_private_key(
271275
ec.SECP384R1(),
272276
backend=default_backend())
@@ -278,7 +282,8 @@ def raw_sign(self, payload):
278282
data=payload,
279283
signature_algorithm=ec.ECDSA(SHA384()))
280284

281-
def sign(self, payload):
285+
@override
286+
def sign(self, payload: bytes) -> bytes:
282287
sig = self.raw_sign(payload)
283288
if self.pad_sig:
284289
# To make fixed length, pad with one or two zeros.

scripts/imgtool/keys/ed25519.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
# SPDX-License-Identifier: Apache-2.0
66

7+
from __future__ import annotations
8+
79
from cryptography.hazmat.primitives import serialization
810
from cryptography.hazmat.primitives.asymmetric import ed25519
911

10-
from .general import KeyClass
12+
from .general import DigestSigner, KeyClass, override
1113

1214

1315
class Ed25519UsageError(Exception):
@@ -69,7 +71,7 @@ def verify_digest(self, signature, digest):
6971
return k.verify(signature=signature, data=digest)
7072

7173

72-
class Ed25519(Ed25519Public):
74+
class Ed25519(Ed25519Public, DigestSigner):
7375
"""
7476
Wrapper around an ED25519 private key.
7577
"""
@@ -79,7 +81,7 @@ def __init__(self, key):
7981
self.key = key
8082

8183
@staticmethod
82-
def generate():
84+
def generate() -> Ed25519:
8385
pk = ed25519.Ed25519PrivateKey.generate()
8486
return Ed25519(pk)
8587

@@ -105,6 +107,7 @@ def export_private(self, path, passwd=None):
105107
with open(path, 'wb') as f:
106108
f.write(pem)
107109

108-
def sign_digest(self, digest):
110+
@override
111+
def sign_digest(self, digest: bytes) -> bytes:
109112
"""Return the actual signature"""
110113
return self.key.sign(data=digest)

scripts/imgtool/keys/general.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,41 @@
22

33
# SPDX-License-Identifier: Apache-2.0
44

5+
from __future__ import annotations
6+
57
import os
68
import sys
9+
from typing import Protocol, runtime_checkable
710

811
from cryptography.hazmat.primitives.hashes import SHA256, Hash
912

13+
if sys.version_info >= (3, 12):
14+
from typing import override as override
15+
else:
16+
try:
17+
from typing_extensions import override as override
18+
except ImportError: # pragma: no cover
19+
def override(func): # type: ignore[no-redef]
20+
"""Runtime no-op fallback when typing_extensions is absent."""
21+
return func
22+
1023
AUTOGEN_MESSAGE = "/* Autogenerated by imgtool.py, do not edit. */"
1124

1225

26+
@runtime_checkable
27+
class PayloadSigner(Protocol):
28+
"""A key capable of signing a payload."""
29+
30+
def sign(self, payload: bytes) -> bytes: ...
31+
32+
33+
@runtime_checkable
34+
class DigestSigner(Protocol):
35+
"""A key capable of signing a digest (hash) of a payload."""
36+
37+
def sign_digest(self, digest: bytes) -> bytes: ...
38+
39+
1340
class FileHandler:
1441
def __init__(self, file, *args, **kwargs):
1542
self.file_in = file

scripts/imgtool/keys/rsa.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
# SPDX-License-Identifier: Apache-2.0
66

7+
from __future__ import annotations
8+
79
from cryptography.hazmat.backends import default_backend
810
from cryptography.hazmat.primitives import serialization
911
from cryptography.hazmat.primitives.asymmetric import rsa
1012
from cryptography.hazmat.primitives.asymmetric.padding import MGF1, PSS
1113
from cryptography.hazmat.primitives.hashes import SHA256
1214

13-
from .general import KeyClass
15+
from .general import KeyClass, PayloadSigner, override
1416
from .privatebytes import PrivateBytesMixin
1517

1618
# Sizes that bootutil will recognize
@@ -81,7 +83,7 @@ def verify(self, signature, payload):
8183
algorithm=SHA256())
8284

8385

84-
class RSA(RSAPublic, PrivateBytesMixin):
86+
class RSA(RSAPublic, PrivateBytesMixin, PayloadSigner):
8587
"""
8688
Wrapper around an RSA key, with imgtool support.
8789
"""
@@ -91,7 +93,7 @@ def __init__(self, key):
9193
self.key = key
9294

9395
@staticmethod
94-
def generate(key_size=2048):
96+
def generate(key_size: int = 2048) -> RSA:
9597
if key_size not in RSA_KEY_SIZES:
9698
raise RSAUsageError(f"Key size {key_size} is not supported by MCUboot"
9799
)
@@ -163,7 +165,8 @@ def export_private(self, path, passwd=None):
163165
with open(path, 'wb') as f:
164166
f.write(pem)
165167

166-
def sign(self, payload):
168+
@override
169+
def sign(self, payload: bytes) -> bytes:
167170
# The verification code only allows the salt length to be the
168171
# same as the hash length, 32.
169172
return self.key.sign(

scripts/imgtool/keys/x25519.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
# SPDX-License-Identifier: Apache-2.0
66

7+
from __future__ import annotations
8+
79
from cryptography.hazmat.primitives import serialization
810
from cryptography.hazmat.primitives.asymmetric import x25519
911

10-
from .general import KeyClass
12+
from .general import DigestSigner, KeyClass, override
1113
from .privatebytes import PrivateBytesMixin
1214

1315

@@ -63,7 +65,7 @@ def sig_len(self):
6365
return 32
6466

6567

66-
class X25519(X25519Public, PrivateBytesMixin):
68+
class X25519(X25519Public, PrivateBytesMixin, DigestSigner):
6769
"""
6870
Wrapper around an X25519 private key.
6971
"""
@@ -73,7 +75,7 @@ def __init__(self, key):
7375
self.key = key
7476

7577
@staticmethod
76-
def generate():
78+
def generate() -> X25519:
7779
pk = x25519.X25519PrivateKey.generate()
7880
return X25519(pk)
7981

@@ -106,7 +108,8 @@ def export_private(self, path, passwd=None):
106108
with open(path, 'wb') as f:
107109
f.write(pem)
108110

109-
def sign_digest(self, digest):
111+
@override
112+
def sign_digest(self, digest: bytes) -> bytes:
110113
"""Return the actual signature"""
111114
return self.key.sign(data=digest)
112115

scripts/imgtool/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,11 @@ def sign(key, public_key_format, align, version, pad_sig, header_size,
481481
compression_tlvs = {}
482482
img.load(infile)
483483
key = load_key(key) if key else None
484+
if key is not None and not isinstance(key, (keys.PayloadSigner, keys.DigestSigner)):
485+
raise click.UsageError(
486+
"Cannot sign with a public-only PEM; signing requires the "
487+
"private key."
488+
)
484489
enckey = load_key(encrypt) if encrypt else None
485490
if enckey and key and ((isinstance(key, keys.ECDSA256P1) and
486491
not isinstance(enckey, keys.ECDSA256P1Public))

0 commit comments

Comments
 (0)