diff --git a/docs/release-notes.d/add-imgtool-pkcs11-ecdsa.md b/docs/release-notes.d/add-imgtool-pkcs11-ecdsa.md new file mode 100644 index 0000000000..77d4de6680 --- /dev/null +++ b/docs/release-notes.d/add-imgtool-pkcs11-ecdsa.md @@ -0,0 +1 @@ +- Added support for PKCS#11 URIs and ECDSA-P384 keys. \ No newline at end of file diff --git a/scripts/imgtool/image.py b/scripts/imgtool/image.py index 7f485bcc8c..f3b7a944ee 100755 --- a/scripts/imgtool/image.py +++ b/scripts/imgtool/image.py @@ -1,6 +1,6 @@ # Copyright 2018 Nordic Semiconductor ASA # Copyright 2017-2020 Linaro Limited -# Copyright 2019-2024 Arm Limited +# Copyright 2019-2025 Arm Limited # # SPDX-License-Identifier: Apache-2.0 # @@ -184,6 +184,7 @@ def tlv_sha_to_sha(tlv): ALLOWED_KEY_SHA = { keys.ECDSA384P1 : ['384'], keys.ECDSA384P1Public : ['384'], + keys.PKCS11 : ['384'], keys.ECDSA256P1 : ['256'], keys.ECDSA256P1Public : ['256'], keys.RSA : ['256'], @@ -220,7 +221,7 @@ def key_and_user_sha_to_alg_and_tlv(key, user_sha, is_pure = False): allowed = allowed_key_ssh[type(key)] except KeyError: - raise click.UsageError("Colud not find allowed hash algorithms for {}" + raise click.UsageError("Could not find allowed hash algorithms for {}" .format(type(key))) # Pure enforces auto, and user selection is ignored diff --git a/scripts/imgtool/keys/__init__.py b/scripts/imgtool/keys/__init__.py index ed2fed57e9..16577bba91 100644 --- a/scripts/imgtool/keys/__init__.py +++ b/scripts/imgtool/keys/__init__.py @@ -30,12 +30,18 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey) +import pkcs11 +import pkcs11.exceptions +import sys + from .rsa import RSA, RSAPublic, RSAUsageError, RSA_KEY_SIZES from .ecdsa import (ECDSA256P1, ECDSA256P1Public, ECDSA384P1, ECDSA384P1Public, ECDSAUsageError) from .ed25519 import Ed25519, Ed25519Public, Ed25519UsageError from .x25519 import X25519, X25519Public, X25519UsageError +from .imgtool_keys_pkcs11 import PKCS11 + class PasswordRequired(Exception): """Raised to indicate that the key is password protected, but a @@ -44,6 +50,19 @@ class PasswordRequired(Exception): def load(path, passwd=None): + if path.startswith("pkcs11:"): + try: + return PKCS11(path) # assume a PKCS #11 URI according to RFC7512 + except pkcs11.exceptions.PinIncorrect: + print('ERROR: WRONG PIN') + sys.exit(1) + except pkcs11.exceptions.PinLocked: + print('ERROR: WRONG PIN, MAX ATTEMPTS REACHED. CONTACT YOUR SECURITY OFFICER.') + sys.exit(1) + except pkcs11.exceptions.DataLenRange: + print('ERROR: PIN IS TOO SHORT OR TOO LONG') + sys.exit(1) + """Try loading a key from the given path. Returns None if the password wasn't specified.""" with open(path, 'rb') as f: diff --git a/scripts/imgtool/keys/imgtool_keys_pkcs11.py b/scripts/imgtool/keys/imgtool_keys_pkcs11.py new file mode 100644 index 0000000000..2ee9593c83 --- /dev/null +++ b/scripts/imgtool/keys/imgtool_keys_pkcs11.py @@ -0,0 +1,202 @@ +""" +PKCS11 key management +""" +# SPDX-License-Identifier: Apache-2.0 + +import hashlib +import os +import pkcs11 +import pkcs11.util.ec + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import ( + load_der_public_key, + Encoding, + PublicFormat +) +from cryptography.hazmat.primitives.asymmetric.ec import ( + ECDSA, SECP256R1, SECP384R1, + EllipticCurvePublicKey +) +from urllib.parse import unquote, urlparse + +from .general import KeyClass + + +def unquote_to_bytes(urlencoded_string): + """Replace %xx escapes by their single-character equivalent, + using the “iso-8859-1” encoding to decode all 8-bit values. + """ + return bytes( + unquote(urlencoded_string, encoding='iso-8859-1'), + encoding='iso-8859-1' + ) + +def get_pkcs11_uri_params(uri): + """Return a dict of decoded URI key=val pairs + """ + uri_tokens = urlparse(uri) + assert uri_tokens.scheme == 'pkcs11' + assert uri_tokens.query == '' + assert uri_tokens.fragment == '' + return { + unquote_to_bytes(key): unquote_to_bytes(value) + for key, value + in [ + line.split('=') + for line + in uri_tokens.path.split(';') + ] + } + +class PKCS11UsageError(Exception): + pass + + +class PKCS11(KeyClass): + """ + Wrapper around an ECDSA P384 key accessed via PKCS#11 URIs + """ + def __init__(self, uri, env=None): + if env is None: + env = os.environ + if 'PKCS11_PIN' not in env: + raise RuntimeError("Environment variable PKCS11_PIN not set. Set it to the user PIN.") + params = get_pkcs11_uri_params(uri) + assert b'serial' in params + assert b'id' in params or b'label' in params + self.user_pin = env['PKCS11_PIN'] + + # Fall back to OpenSC + pkcs11_module_path = env.get('PKCS11_MODULE', 'opensc-pkcs11.so') + + try: + lib = pkcs11.lib(pkcs11_module_path) + except RuntimeError: + raise RuntimeError(f"PKCS11 module {pkcs11_module_path} not loaded.") + + self.token = lib.get_token(token_serial=params[b'serial']) + # try to open a session to see if the PIN is valid + with self.token.open(user_pin=self.user_pin) as _: + pass + self.key_id = params.get(b'id', None) + self.key_label = params.get(b'label', None) + self.key_label = self.key_label.decode('utf-8') if self.key_label else None + + def shortname(self): + return "ecdsa" + + def _unsupported(self, name): + raise PKCS11UsageError(f"Operation {name} requires private key") + + def get_public_bytes(self): + with self.token.open(user_pin=self.user_pin) as session: + pub = session.get_key( + id=self.key_id, + label=self.key_label, + key_type=pkcs11.KeyType.EC, + object_class=pkcs11.ObjectClass.PUBLIC_KEY + ) + key = pkcs11.util.ec.encode_ec_public_key(pub) + return key + + def get_private_bytes(self, minimal): + self._unsupported('get_private_bytes') + + def export_private(self, path, passwd=None): + self._unsupported('export_private') + + def export_public(self, path): + """Write the public key to the given file.""" + with self.token.open(user_pin=self.user_pin) as session: + pub = session.get_key( + id=self.key_id, + label=self.key_label, + key_type=pkcs11.KeyType.EC, + object_class=pkcs11.ObjectClass.PUBLIC_KEY + ) + # Encode to DER + der_bytes = pkcs11.util.ec.encode_ec_public_key(pub) + + # Convert to PEM using cryptography + public_key = load_der_public_key(der_bytes) + pem = public_key.public_bytes( + encoding=Encoding.PEM, + format=PublicFormat.SubjectPublicKeyInfo + ) + + with open(path, 'wb') as f: + f.write(pem) + + def sig_type(self): + return "ECDSA384_SHA384" + + def sig_tlv(self): + return "ECDSASIG" + + def sig_len(self): + # Early versions of MCUboot (< v1.5.0) required ECDSA + # signatures to be padded to a fixed length. Because the DER + # encoding is done with signed integers, the size of the + # signature will vary depending on whether the high bit is set + # in each value. This padding was done in a + # not-easily-reversible way (by just adding zeros). + # + # The signing code no longer requires this padding, and newer + # versions of MCUboot don't require it. But, continue to + # return the total length so that the padding can be done if + # requested. + return 103 + + def raw_sign(self, payload): + """Return the actual signature""" + with self.token.open(user_pin=self.user_pin) as session: + priv = session.get_key( + id=self.key_id, + label=self.key_label, + key_type=pkcs11.KeyType.EC, + object_class=pkcs11.ObjectClass.PRIVATE_KEY + ) + sig = priv.sign( + hashlib.sha384(payload).digest(), + mechanism=pkcs11.Mechanism.ECDSA + ) + return pkcs11.util.ec.encode_ecdsa_signature(sig) + + def sign(self, payload): + """Return signature with legacy padding""" + # To make fixed length, pad with one or two zeros. + while True: + sig = self.raw_sign(payload) + if sig[-1] != 0x00: + break + + sig += b'\000' * (self.sig_len() - len(sig)) + return sig + + def verify(self, signature, payload): + """Verify the signature of the payload""" + # strip possible paddings added during sign + signature = signature[:signature[1] + 2] + + # Load public key from DER bytes + public_key = load_der_public_key(self.get_public_bytes()) + + if not isinstance(public_key, EllipticCurvePublicKey): + raise TypeError(f"Unsupported key type: {type(public_key).__name__}") + + # Determine correct hash algorithm based on curve + if isinstance(public_key.curve, SECP256R1): + hash_alg = hashes.SHA256() + elif isinstance(public_key.curve, SECP384R1): + hash_alg = hashes.SHA384() + else: + raise ValueError(f"Unsupported curve: {public_key.curve.name}") + + try: + # Attempt ECDSA verification + public_key.verify(signature, payload, ECDSA(hash_alg)) + return True + except InvalidSignature: + return False diff --git a/scripts/imgtool/keys/imgtool_keys_pkcs11_test.py b/scripts/imgtool/keys/imgtool_keys_pkcs11_test.py new file mode 100644 index 0000000000..351214426a --- /dev/null +++ b/scripts/imgtool/keys/imgtool_keys_pkcs11_test.py @@ -0,0 +1,230 @@ +""" +Tests for PKCS11 keys +""" +# SPDX-License-Identifier: Apache-2.0 + +import io +import os.path +import pkcs11 +import pkcs11.exceptions +import sys +import tempfile +import unittest + +from datetime import datetime +from pkcs11.util import ec +from urllib.parse import quote + +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), '../..'))) + +from imgtool.keys import load, PKCS11 +from imgtool.keys.imgtool_keys_pkcs11 import \ + get_pkcs11_uri_params, unquote_to_bytes + +class GetPKCS11URLParams(unittest.TestCase): + def setUp(self): + pass + + def test_unquote_verbatim(self): + for i in range(0, 256): + with self.subTest(i=i): + urlencoded_string = '%%%2.2x' % i + actual_bytes = unquote_to_bytes(urlencoded_string) + expected_bytes = bytes([i]) + self.assertEqual( + actual_bytes, + expected_bytes + ) + + def test_get_pkcs11_uri_params(self): + url = 'pkcs11:model=PKCS%2315%20emulated;manufacturer=www.CardContact.de;serial=DENK0103525;token=SmartCard-HSM%20%28UserPIN%29;id=%9E%81%81%27%0C%DE%85%32%75%86%61%E9%87%9A%69%E8%5E%9B%4F%24;object=2020-10-14-mcuboot;type=private' + actual_params = get_pkcs11_uri_params(url) + expected_params = { + b'model': b'PKCS#15 emulated', + b'manufacturer': b'www.CardContact.de', + b'serial': b'DENK0103525', + b'token': b'SmartCard-HSM (UserPIN)', + b'id': b'\x9E\x81\x81\x27\x0C\xDE\x85\x32\x75\x86\x61\xE9\x87\x9A\x69\xE8\x5E\x9B\x4F\x24', + b'object': b'2020-10-14-mcuboot', + b'type': b'private' + } + self.assertEqual( + actual_params, + expected_params + ) + + +class EcKeyGeneration(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory() + self.lib_path = os.environ.get('PKCS11_MODULE', 'opensc-pkcs11.so') + lib = pkcs11.lib(self.lib_path) + # There may be multiple tokens. Pick the first. + slot = lib.get_slots(token_present=True)[0] + self.token = slot.get_token() + self.serial = self.token.serial.decode("iso-8859-1") + self.user_pin = os.environ['PKCS11_PIN'] + + with self.token.open(user_pin=self.user_pin, rw=True) as session: + ecparams = session.create_domain_parameters( + pkcs11.KeyType.EC, + { + pkcs11.Attribute.EC_PARAMS: ec.encode_named_curve_parameters('secp384r1'), + }, + local=True + ) + timestamp = datetime.now().isoformat() + pubkey, privkey = ecparams.generate_keypair( + store=True, + label=f"imgtool.py test key {timestamp}" + ) + self.key_id = privkey.id + self.pkcs11_uri = f"pkcs11:serial={self.serial};id={quote(self.key_id)}" + + def tname(self, base): + return os.path.join( + self.test_dir.name, + base + ) + + def tearDown(self): + self.test_dir.cleanup() + with self.token.open(user_pin=self.user_pin, rw=True) as session: + privkey = session.get_key( + id=self.key_id, + key_type=pkcs11.KeyType.EC, + object_class=pkcs11.ObjectClass.PRIVATE_KEY + ) + privkey.destroy() + pubkey = session.get_key( + id=self.key_id, + key_type=pkcs11.KeyType.EC, + object_class=pkcs11.ObjectClass.PUBLIC_KEY + ) + pubkey.destroy() + + def test_emit(self): + """Basic smoke test on the code emitters.""" + k = PKCS11(uri=self.pkcs11_uri) + + ccode = io.StringIO() + k.emit_c_public(ccode) + self.assertIn( + "ecdsa_pub_key", + ccode.getvalue(), + ) + self.assertIn( + "ecdsa_pub_key_len", + ccode.getvalue(), + ) + + rustcode = io.StringIO() + k.emit_rust_public(rustcode) + self.assertIn( + "ECDSA_PUB_KEY", + rustcode.getvalue(), + ) + + def test_emit_pub(self): + """Basic smoke test on the code emitters.""" + pubname = self.tname("public.pem") + k = PKCS11(uri=self.pkcs11_uri) + k.export_public(pubname) + + k2 = load(pubname) + + ccode = io.StringIO() + k2.emit_c_public(ccode) + self.assertIn( + "ecdsap384_pub_key", + ccode.getvalue(), + ) + self.assertIn( + "ecdsap384_pub_key_len", + ccode.getvalue(), + ) + + rustcode = io.StringIO() + k2.emit_rust_public(rustcode) + self.assertIn( + "ECDSAP384_PUB_KEY", + rustcode.getvalue(), + ) + + def test_sig(self): + k = PKCS11(uri=self.pkcs11_uri) + buf = b'This is the message' + sig = k.raw_sign(buf) + + self.assertTrue(k.verify( + signature=sig, + payload=buf)) + + self.assertFalse(k.verify( + signature=sig, + payload=b'This is not the message')) + + def clone_env(self): + # PKCS module can only support one loaded library. + # Ensure tests use the same one used by setUp. + return { + 'PKCS11_PIN': os.environ['PKCS11_PIN'], + 'PKCS11_MODULE': os.environ.get('PKCS11_MODULE','opensc-pkcs11.so') + } + + def test_env(self): + env = self.clone_env() + with self.assertRaises( + pkcs11.exceptions.NoSuchToken + ): + PKCS11(uri='pkcs11:serial=bogus;id=00', env=env) + + env = self.clone_env() + del env['PKCS11_PIN'] + with self.assertRaises(RuntimeError) as context_manager: + PKCS11( + uri=self.pkcs11_uri, + env=env, + ) + self.assertEqual( + str(context_manager.exception), + 'Environment variable PKCS11_PIN not set. Set it to the user PIN.' + ) + + env = self.clone_env() + env['PKCS11_PIN'] = 'notavalidpin' + with self.assertRaises(pkcs11.exceptions.PinIncorrect): + PKCS11( + uri=self.pkcs11_uri, + env=env, + ) + + env = self.clone_env() + with self.assertRaises(pkcs11.exceptions.NoSuchKey): + pkcs11_uri = f"pkcs11:serial={self.serial};id=00" + k = PKCS11( + uri=pkcs11_uri, + env=env, + ) + k.raw_sign(payload=b'test') + + def test_sig_statistical(self): + k = PKCS11(uri=self.pkcs11_uri) + buf = b'This is the message' + + total = 173 + for n in range(total): + sys.stdout.write(f'\rtest signature {n} / {total} ...') + sys.stdout.flush() + + sig = k.raw_sign(buf) + + self.assertTrue(k.verify( + signature=sig, + payload=buf, + )) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/imgtool/main.py b/scripts/imgtool/main.py index 7f9a536571..8388fdff93 100755 --- a/scripts/imgtool/main.py +++ b/scripts/imgtool/main.py @@ -1,7 +1,7 @@ #! /usr/bin/env python3 # # Copyright 2017-2020 Linaro Limited -# Copyright 2019-2023 Arm Limited +# Copyright 2019-2025 Arm Limited # # SPDX-License-Identifier: Apache-2.0 # @@ -150,7 +150,8 @@ def keygen(type, key, password): @click.option('-e', '--encoding', metavar='encoding', type=click.Choice(valid_encodings), help='Valid encodings: {}'.format(', '.join(valid_encodings))) -@click.option('-k', '--key', metavar='filename', required=True) +@click.option('-k', '--key', metavar='filename', required=True, + help='Public key filename or PKCS #11 URI') @click.option('-o', '--output', metavar='output', required=False, help='Specify the output file\'s name. \ The stdout is used if it is not provided.') @@ -232,7 +233,8 @@ def getpriv(key, minimal, format): @click.argument('imgfile') -@click.option('-k', '--key', metavar='filename') +@click.option('-k', '--key', metavar='filename', + help='Public key filename or PKCS #11 URI') @click.command(help="Check that signed image can be verified by given key") def verify(key, imgfile): key = load_key(key) if key else None @@ -448,7 +450,8 @@ def convert(self, value, param, ctx): @click.option('--public-key-format', type=click.Choice(['hash', 'full']), default='hash', help='In what format to add the public key to ' 'the image manifest: full key or hash of the key.') -@click.option('-k', '--key', metavar='filename') +@click.option('-k', '--key', metavar='filename', + help='Private key filename or PKCS #11 URI') @click.option('--fix-sig', metavar='filename', help='fixed signature for the image. It will be used instead of ' 'the signature calculated using the public key') diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 33b11bb540..b8e5ce02ca 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -5,3 +5,4 @@ cbor2 setuptools pyyaml pytest +python-pkcs11