diff --git a/scripts/bootloader/do_sign.py b/scripts/bootloader/do_sign.py old mode 100644 new mode 100755 index 842c7e6443c1..4672714324d3 --- a/scripts/bootloader/do_sign.py +++ b/scripts/bootloader/do_sign.py @@ -3,37 +3,103 @@ # Copyright (c) 2018 Nordic Semiconductor ASA # # SPDX-License-Identifier: LicenseRef-Nordic-5-Clause +from __future__ import annotations - -import sys import argparse +import contextlib import hashlib -from ecdsa import SigningKey +import sys +from pathlib import Path +from typing import BinaryIO, Generator + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from ecdsa.keys import SigningKey # type: ignore[import-untyped] +from intelhex import IntelHex # type: ignore[import-untyped] -def parse_args(): +def parse_args(argv=None): parser = argparse.ArgumentParser( description='Sign data from stdin or file.', formatter_class=argparse.RawDescriptionHelpFormatter, - allow_abbrev=False) + allow_abbrev=False + ) - parser.add_argument('-k', '--private-key', required=True, type=argparse.FileType('rb'), + parser.add_argument('-k', '--private-key', required=True, type=Path, help='Private key to use.') parser.add_argument('-i', '--in', '-in', required=False, dest='infile', - type=argparse.FileType('rb'), default=sys.stdin.buffer, + type=Path, default=sys.stdin.buffer, help='Sign the contents of the specified file instead of stdin.') parser.add_argument('-o', '--out', '-out', required=False, dest='outfile', - type=argparse.FileType('wb'), default=sys.stdout.buffer, + type=Path, default=None, help='Write the signature to the specified file instead of stdout.') + parser.add_argument( + '--algorithm', '-a', dest='algorithm', + help='Signing algorithm (default: %(default)s)', + action='store', choices=['ecdsa', 'ed25519'], default='ecdsa', + ) - args = parser.parse_args() + args = parser.parse_args(argv) return args -if __name__ == '__main__': - args = parse_args() - private_key = SigningKey.from_pem(args.private_key.read()) - data = args.infile.read() +@contextlib.contextmanager +def open_stream(output_file: Path | None = None) -> Generator[BinaryIO, None, None]: + if output_file is not None: + stream = open(output_file, 'wb') + try: + yield stream + finally: + stream.close() + else: + yield sys.stdout.buffer + + +def hex_to_binary(input_hex_file: str) -> bytes: + ih = IntelHex(input_hex_file) + ih.padding = 0xff # Allows hashing with empty regions + data = ih.tobinstr() + return data + + +def sign_with_ecdsa( + private_key_file: Path, input_file: Path, output_file: Path | None = None +) -> int: + with open(private_key_file, 'r') as f: + private_key = SigningKey.from_pem(f.read()) + with open(input_file, 'rb') as f: + data = f.read() signature = private_key.sign(data, hashfunc=hashlib.sha256) - args.outfile.write(signature) + with open_stream(output_file) as stream: + stream.write(signature) + return 0 + + +def sign_with_ed25519( + private_key_file: Path, input_file: Path, output_file: Path | None = None +) -> int: + with open(private_key_file, 'rb') as f: + private_key: Ed25519PrivateKey = load_pem_private_key(f.read(), password=None) # type: ignore[assignment] + if str(input_file).endswith('.hex'): + data = hex_to_binary(str(input_file)) + else: + with open(input_file, 'rb') as f: + data = f.read() + signature = private_key.sign(data) + with open_stream(output_file) as stream: + stream.write(signature) + return 0 + + +def main(argv=None) -> int: + args = parse_args(argv) + if args.algorithm == 'ecdsa': + return sign_with_ecdsa(args.private_key, args.infile, args.outfile) + if args.algorithm == 'ed25519': + return sign_with_ed25519(args.private_key, args.infile, args.outfile) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/bootloader/hash.py b/scripts/bootloader/hash.py old mode 100644 new mode 100755 index 3581c0d9989e..dc53f5e5ee28 --- a/scripts/bootloader/hash.py +++ b/scripts/bootloader/hash.py @@ -4,11 +4,20 @@ # # SPDX-License-Identifier: LicenseRef-Nordic-5-Clause +""" +Hash content of a file. +""" +import argparse import hashlib import sys -import argparse -from intelhex import IntelHex + +from intelhex import IntelHex # type: ignore[import-untyped] + +HASH_FUNCTION_FACTORY = { + 'sha256': hashlib.sha256, + 'sha512': hashlib.sha512, +} def parse_args(): @@ -17,20 +26,37 @@ def parse_args(): formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False) - parser.add_argument('--infile', '-i', '--in', '-in', required=True, - help='Hash the contents of the specified file. If a *.hex file is given, the contents will ' - 'first be converted to binary, with all non-specified area being set to 0xff. ' - 'For all other file types, no conversion is done.') - return parser.parse_args() + parser.add_argument( + '--infile', '-i', '--in', '-in', required=True, + help='Hash the contents of the specified file. If a *.hex file is given, the contents will ' + 'first be converted to binary, with all non-specified area being set to 0xff. ' + 'For all other file types, no conversion is done.' + ) + parser.add_argument( + '--type', '-t', dest='hash_function', help='Hash function (default: %(default)s)', + action='store', choices=HASH_FUNCTION_FACTORY.keys(), default='sha256' + ) + return parser.parse_args() -if __name__ == '__main__': - args = parse_args() - if args.infile.endswith('.hex'): - ih = IntelHex(args.infile) +def generate_hash_digest(file: str, hash_function_name: str) -> bytes: + if file.endswith('.hex'): + ih = IntelHex(file) ih.padding = 0xff # Allows hashing with empty regions to_hash = ih.tobinstr() else: - to_hash = open(args.infile, 'rb').read() - sys.stdout.buffer.write(hashlib.sha256(to_hash).digest()) + to_hash = open(file, 'rb').read() + + hash_function = HASH_FUNCTION_FACTORY[hash_function_name] + return hash_function(to_hash).digest() + + +def main(): + args = parse_args() + sys.stdout.buffer.write(generate_hash_digest(args.infile, args.hash_function)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/bootloader/keygen.py b/scripts/bootloader/keygen.py old mode 100644 new mode 100755 index 871e4fd44db9..a87b79df81cb --- a/scripts/bootloader/keygen.py +++ b/scripts/bootloader/keygen.py @@ -4,16 +4,22 @@ # # SPDX-License-Identifier: LicenseRef-Nordic-5-Clause +from __future__ import annotations -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import load_pem_private_key as load_pem -from hashlib import sha256 import argparse import sys +from hashlib import sha256, sha512 +from typing import BinaryIO +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.serialization import load_pem_private_key -def generate_legal_key(): + +def generate_legal_key_for_elliptic_curve(): """ Ensure that we don't have 0xFFFF in the hash of the public key of the generated keypair. @@ -23,8 +29,8 @@ def generate_legal_key(): while True: key = ec.generate_private_key(ec.SECP256R1()) public_bytes = key.public_key().public_bytes( - encoding=serialization.Encoding.X962, - format=serialization.PublicFormat.UncompressedPoint, + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, ) # The digest don't contain the first byte as it denotes @@ -35,7 +41,148 @@ def generate_legal_key(): return key -if __name__ == '__main__': +def generate_legal_key_for_ed25519(): + """ + Ensure that we don't have 0xFFFF in the hash of the public key of + the generated keypair. + + :return: A key who's SHA512 digest does not contain 0xFFFF + """ + while True: + key = ed25519.Ed25519PrivateKey.generate() + public_bytes = key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + # The digest don't contain the first byte as it denotes + # if it is compressed/UncompressedPoint. + digest = sha512(public_bytes[1:]).digest()[:16] + if not any([digest[n:n + 2] == b'\xff\xff' for n in range(0, len(digest), 2)]): + return key + + +class EllipticCurveKeysGenerator: + """Generate private and public keys for Elliptic Curve cryptography.""" + + def __init__(self, infile: BinaryIO | None = None) -> None: + """ + :param infile: A file-like object to read the private key. + """ + if infile is None: + self.private_key = generate_legal_key_for_elliptic_curve() + else: + self.private_key = load_pem_private_key(infile.read(), password=None) + self.public_key = self.private_key.public_key() + + @property + def private_key_pem(self) -> bytes: + return self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + def write_private_key_pem(self, outfile: BinaryIO) -> bytes: + """ + Write private key pem to file and return it. + + :param outfile: A file-like object to write the private key. + """ + if outfile is not None: + outfile.write(self.private_key_pem) + return self.private_key_pem + + @property + def public_key_pem(self) -> bytes: + return self.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def write_public_key_pem(self, outfile: BinaryIO) -> bytes: + """ + Write public key pem to file and return it. + + :param outfile: A file-like object to write the public key. + """ + outfile.write(self.public_key_pem) + return self.public_key_pem + + @staticmethod + def verify_signature(public_key, message: bytes, signature: bytes) -> bool: + try: + public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) + return True + except InvalidSignature: + return False + + @staticmethod + def sign_message(private_key, message: bytes) -> bytes: + return private_key.sign(message, ec.ECDSA(hashes.SHA256())) + + +class Ed25519KeysGenerator: + """Generate private and public keys for ED25519 cryptography.""" + + def __init__(self, infile: BinaryIO | None = None) -> None: + """ + :param infile: A file-like object to read the private key. + """ + if infile is None: + self.private_key: ed25519.Ed25519PrivateKey = generate_legal_key_for_ed25519() + else: + self.private_key = load_pem_private_key(infile.read(), password=None) # type: ignore[assignment] + self.public_key: ed25519.Ed25519PublicKey = self.private_key.public_key() + + @property + def private_key_pem(self) -> bytes: + return self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + + def write_private_key_pem(self, outfile: BinaryIO) -> bytes: + """ + Write private key pem to file and return it. + + :param outfile: A file-like object to write the private key. + """ + outfile.write(self.private_key_pem) + return self.private_key_pem + + @property + def public_key_pem(self) -> bytes: + return self.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + def write_public_key_pem(self, outfile: BinaryIO) -> bytes: + """ + Write public key pem to file and return it. + + :param outfile: A file-like object to write the public key. + """ + if outfile is not None: + outfile.write(self.public_key_pem) + return self.public_key_pem + + @staticmethod + def verify_signature(public_key: ed25519.Ed25519PublicKey, message: bytes, signature: bytes) -> bool: + try: + public_key.verify(signature, message) + return True + except InvalidSignature: + return False + + @staticmethod + def sign_message(private_key, message: bytes) -> bytes: + return private_key.sign(message) + + +def main(argv=None) -> int: parser = argparse.ArgumentParser( description='Generate PEM file.', formatter_class=argparse.RawDescriptionHelpFormatter, @@ -53,21 +200,28 @@ def generate_legal_key(): type=argparse.FileType('rb'), help='Read private key from specified PEM file instead ' 'of generating it.') + parser.add_argument( + '--algorithm', '-a', help='Signing algorithm (default: %(default)s)', + required=False, action='store', choices=('ec', 'ed25519'), default='ec' + ) + + args = parser.parse_args(argv) + + if args.algorithm == 'ed25519': + ed25519_generator = Ed25519KeysGenerator(args.infile) + if args.private: + ed25519_generator.write_private_key_pem(args.out) + if args.public: + ed25519_generator.write_public_key_pem(args.out) + else: + ec_generator = EllipticCurveKeysGenerator(args.infile) + if args.private: + ec_generator.write_private_key_pem(args.out) + elif args.public: + ec_generator.write_public_key_pem(args.out) - args = parser.parse_args() - sk = (load_pem(args.infile.read(), password=None) if args.infile else generate_legal_key()) - - if args.private: - private_pem = sk.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - args.out.write(private_pem) - - if args.public: - public_pem = sk.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - args.out.write(public_pem) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/bootloader/tests/asn1parse_test.py b/scripts/bootloader/tests/asn1parse_test.py new file mode 100644 index 000000000000..96af37cb5ed3 --- /dev/null +++ b/scripts/bootloader/tests/asn1parse_test.py @@ -0,0 +1,50 @@ +# +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import subprocess + +from asn1parse import get_ecdsa_signature +from keygen import EllipticCurveKeysGenerator + + +def test_asn1parse_with_ecdsa_and_sha256(tmpdir): + signature_der_file = tmpdir / 'signature.bin' + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + generator = EllipticCurveKeysGenerator() + generator.write_private_key_pem(private_key_file) + generator.write_public_key_pem(public_key_file) + + input_file = tmpdir / 'input_file.txt' + message = b'Test message for key verification' + input_file.write(message) + + subprocess.run( + [ + 'openssl', 'dgst', '-sha256', '-sign', private_key_file, + '-out', signature_der_file, input_file + ], + check=True + ) + assert signature_der_file.exists() + + signature = get_ecdsa_signature(signature_der_file.open('rb').read(), clength=32) + assert len(signature) == 64 + result = subprocess.run( + [ + 'openssl', 'dgst', '-sha256', '-verify', public_key_file, + '-signature', signature_der_file, input_file + ] + ) + assert result.returncode == 0, 'Signature does not match' + + input_file.write(b'Test message to fail verification') + result = subprocess.run( + [ + 'openssl', 'dgst', '-sha256', '-verify', public_key_file, + '-signature', signature_der_file, input_file + ], + ) + assert result.returncode == 1 diff --git a/scripts/bootloader/tests/conftest.py b/scripts/bootloader/tests/conftest.py new file mode 100644 index 000000000000..cdaac0c8c0f6 --- /dev/null +++ b/scripts/bootloader/tests/conftest.py @@ -0,0 +1,10 @@ +# +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import sys +from pathlib import Path + +# make all scripts importable in tests +sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/scripts/bootloader/tests/do_sign_test.py b/scripts/bootloader/tests/do_sign_test.py new file mode 100644 index 000000000000..c21bdb988816 --- /dev/null +++ b/scripts/bootloader/tests/do_sign_test.py @@ -0,0 +1,112 @@ +# +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import hashlib + +from ecdsa.keys import VerifyingKey, BadSignatureError # type: ignore[import-untyped] +from ecdsa.util import sigdecode_string # type: ignore[import-untyped] + +from do_sign import sign_with_ecdsa, sign_with_ed25519 +from keygen import Ed25519KeysGenerator, EllipticCurveKeysGenerator + + +def verify_ecdsa_signature(public_key: VerifyingKey, message: bytes, signature: bytes) -> bool: + try: + public_key.verify(signature, message, hashlib.sha256, sigdecode=sigdecode_string) + return True + except BadSignatureError: + return False + + +def test_if_file_is_properly_signed_with_ec_key(tmpdir): + generator = EllipticCurveKeysGenerator() + private_key_file = tmpdir / 'private.pem' + generator.write_private_key_pem(private_key_file) + public_key_file = tmpdir / 'public.pem' + generator.write_public_key_pem(public_key_file) + + input_file = tmpdir / 'input_file.txt' + message = b'Test message for key verification' + input_file.write(message) + + signature_file = tmpdir / 'signature.bin' + + sign_with_ecdsa( + private_key_file=private_key_file, + input_file=input_file, + output_file=signature_file, + ) + + public_key = VerifyingKey.from_pem(public_key_file.open('br').read()) + signature = signature_file.open('rb').read() + assert verify_ecdsa_signature(public_key=public_key, message=message, signature=signature) + + +def test_if_validation_does_not_pass_for_wrong_ec_key(tmpdir): + private_key_file = tmpdir / 'private.pem' + EllipticCurveKeysGenerator().write_private_key_pem(private_key_file) + public_key_file = tmpdir / 'public.pem' + EllipticCurveKeysGenerator().write_public_key_pem(public_key_file) + + input_file = tmpdir / 'input_file.txt' + message = b'Test message for key verification' + input_file.write(message) + + signature_file = tmpdir / 'signature.bin' + + sign_with_ecdsa( + private_key_file=private_key_file, + input_file=input_file, + output_file=signature_file, + ) + + public_key = VerifyingKey.from_pem(public_key_file.open('br').read()) + signature = signature_file.open('rb').read() + assert verify_ecdsa_signature( + public_key=public_key, message=message, signature=signature + ) is False + + +def test_if_validation_does_not_pass_for_wrong_ed25519_key(tmpdir): + generator = Ed25519KeysGenerator() + private_key_file = tmpdir / 'private.pem' + generator.write_private_key_pem(private_key_file) + public_key = generator.public_key + + input_file = tmpdir / 'input_file.txt' + message = b'Test message for key verification' + input_file.write(message) + + signature_file = tmpdir / 'signature.bin' + + sign_with_ed25519( + private_key_file=private_key_file, + input_file=input_file, + output_file=signature_file + ) + assert Ed25519KeysGenerator.verify_signature( + public_key, message, signature_file.open('br').read() + ) + + +def test_if_file_is_properly_signed_with_ed25519_key(tmpdir): + private_key_file = tmpdir / 'private.pem' + Ed25519KeysGenerator().write_private_key_pem(private_key_file) + public_key = Ed25519KeysGenerator().public_key + + input_file = tmpdir / 'input_file.txt' + message = b'Test message for key verification' + input_file.write(message) + + signature_file = tmpdir / 'signature.bin' + + sign_with_ed25519( + private_key_file=private_key_file, + input_file=input_file, + output_file=signature_file + ) + assert Ed25519KeysGenerator.verify_signature( + public_key, message, signature_file.open('br').read() + ) is False diff --git a/scripts/bootloader/tests/keygen_test.py b/scripts/bootloader/tests/keygen_test.py new file mode 100644 index 000000000000..4f166cccf944 --- /dev/null +++ b/scripts/bootloader/tests/keygen_test.py @@ -0,0 +1,127 @@ +# +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import pytest +from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key + +from keygen import Ed25519KeysGenerator, EllipticCurveKeysGenerator + + +@pytest.mark.parametrize( + 'keys_generator', + [EllipticCurveKeysGenerator, Ed25519KeysGenerator], + ids=['ec', 'ed25519'] +) +def test_keys_generator_generates_proper_pem_key(keys_generator): + key_gen = keys_generator() + assert b'-----BEGIN PRIVATE KEY-----' in key_gen.private_key_pem + assert b'-----END PRIVATE KEY-----' in key_gen.private_key_pem + assert b'-----BEGIN PUBLIC KEY-----' in key_gen.public_key_pem + assert b'-----END PUBLIC KEY-----' in key_gen.public_key_pem + + +def test_elliptic_curve_keys_generator(tmpdir): + private_key_file = tmpdir / 'private.pem' + ec_keys_generator_1 = EllipticCurveKeysGenerator() + private_key_pem_1 = ec_keys_generator_1.write_private_key_pem(private_key_file) + public_key_pem_1 = ec_keys_generator_1.public_key_pem + # public key and private should not be the same + assert private_key_pem_1 != public_key_pem_1 + + # test if same private key is loaded from file + ec_keys_generator_2 = EllipticCurveKeysGenerator(private_key_file.open('rb')) + private_key_pem_2 = ec_keys_generator_2.private_key_pem + assert private_key_pem_1 == private_key_pem_2 + + +def test_signing_with_elliptic_curve_with_valid_keys(tmpdir): + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + generator = EllipticCurveKeysGenerator() + generator.write_private_key_pem(private_key_file) + generator.write_public_key_pem(public_key_file) + + message = b'Test message for key verification' + private_key = load_pem_private_key(private_key_file.open('br').read(), password=None) + public_key = load_pem_public_key(public_key_file.open('br').read()) + + signature = EllipticCurveKeysGenerator.sign_message(private_key, message) + assert EllipticCurveKeysGenerator.verify_signature(public_key, message, signature) + + +def test_signing_with_elliptic_curve_with_invalid_private_key(tmpdir): + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + generator = EllipticCurveKeysGenerator() + generator.write_private_key_pem(private_key_file) + + public_generator = EllipticCurveKeysGenerator() + public_generator.write_public_key_pem(public_key_file.open('wb')) + + message = b'Test message for key verification' + private_key = load_pem_private_key(private_key_file.open('br').read(), password=None) + public_key = load_pem_public_key(public_key_file.open('br').read()) + + signature = EllipticCurveKeysGenerator.sign_message(private_key, message) + assert EllipticCurveKeysGenerator.verify_signature(public_key, message, signature) is False + + +def test_ed25519_keys_generator(tmpdir): + private_key_file = tmpdir / 'private.pem' + ec_keys_generator_1 = Ed25519KeysGenerator() + private_key_pem_1 = ec_keys_generator_1.write_private_key_pem(private_key_file) + public_key_pem_1 = ec_keys_generator_1.public_key_pem + assert private_key_pem_1 != public_key_pem_1 + + # test if same private key is loaded from file + ec_keys_generator_2 = Ed25519KeysGenerator(private_key_file.open('rb')) + private_key_pem_2 = ec_keys_generator_2.private_key_pem + assert private_key_pem_1 == private_key_pem_2 + + +def test_signing_with_ed25519_with_valid_keys(tmpdir): + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + generator = Ed25519KeysGenerator() + generator.write_private_key_pem(private_key_file.open('wb')) + generator.write_public_key_pem(public_key_file.open('wb')) + + message = b'Test message for key verification' + private_key = load_pem_private_key(private_key_file.open('br').read(), password=None) + public_key = load_pem_public_key(public_key_file.open('br').read()) + signature = Ed25519KeysGenerator.sign_message(private_key, message) + assert Ed25519KeysGenerator.verify_signature(public_key, message, signature) + + +def test_signing_with_ed25519_with_valid_keys_and_private_key_from_public_pem_file(tmpdir): + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + private_generator = Ed25519KeysGenerator() + private_generator.write_private_key_pem(private_key_file.open('wb')) + + public_generator = Ed25519KeysGenerator(private_key_file.open('rb')) + public_generator.write_public_key_pem(public_key_file.open('wb')) + + message = b'Test message for key verification' + private_key = load_pem_private_key(private_key_file.open('br').read(), password=None) + public_key = load_pem_public_key(public_key_file.open('br').read()) + signature = Ed25519KeysGenerator.sign_message(private_key, message) + assert Ed25519KeysGenerator.verify_signature(public_key, message, signature) + + +def test_signing_with_ed25519_signature_with_invalid_private_key(tmpdir): + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + private_generator = Ed25519KeysGenerator() + private_generator.write_private_key_pem(private_key_file.open('wb')) + + public_generator = Ed25519KeysGenerator() + public_generator.write_public_key_pem(public_key_file.open('wb')) + + message = b'Test message for key verification' + private_key = load_pem_private_key(private_key_file.open('br').read(), password=None) + public_key = load_pem_public_key(public_key_file.open('br').read()) + signature = Ed25519KeysGenerator.sign_message(private_key, message) + assert Ed25519KeysGenerator.verify_signature(public_key, message, signature) is False diff --git a/scripts/bootloader/tests/validation_data_test.py b/scripts/bootloader/tests/validation_data_test.py new file mode 100644 index 000000000000..3265c0d7170b --- /dev/null +++ b/scripts/bootloader/tests/validation_data_test.py @@ -0,0 +1,134 @@ +# +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import hashlib +from pathlib import Path + +import ecdsa # type: ignore[import-untyped] + +import do_sign +from hash import generate_hash_digest +from keygen import Ed25519KeysGenerator, EllipticCurveKeysGenerator +from validation_data import ( + Ed25519SignatureValidator, + EcdsaSignatureValidator, + main as validation_data_main +) + + +def test_data_validation_for_ec(tmpdir): + magic_value = '0x281ee6de,0x86518483,79362' + offset = 0 + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + zephyr_hex_file = Path(__file__).parent / 'dummy_zephyr.hex' + message_signature_file = tmpdir / 'zephyr.signature' + output_hex_file = tmpdir / 'output.hex' + hash_file = tmpdir / 'hash.256' + + keys_generator = EllipticCurveKeysGenerator() + keys_generator.write_private_key_pem(private_key_file) + keys_generator.write_public_key_pem(public_key_file) + + hash_file.open('wb').write(generate_hash_digest(str(zephyr_hex_file), 'sha256')) + + do_sign.sign_with_ecdsa(private_key_file, hash_file, message_signature_file) + + public_key = ecdsa.VerifyingKey.from_pem(public_key_file.read()) + EcdsaSignatureValidator(hashfunc=hashlib.sha256).append_validation_data( + signature_file=message_signature_file, + input_file=zephyr_hex_file, + public_key=public_key, + offset=offset, + output_hex=output_hex_file.open('w'), + output_bin=None, + magic_value=magic_value + ) + # check with CLI command too + assert validation_data_main( + [ + '--algorithm=ecdsa', + '--signature', str(message_signature_file), + '--input', str(zephyr_hex_file), + '--public-key', str(public_key_file), + '--offset', offset, + '--output-hex', str(output_hex_file), + '--magic-value', magic_value + ] + ) == 0 + # TODO: check output_hex_file + + +def test_data_validation_for_ed25519(tmpdir): + magic_value = '0x281ee6de,0x86518483,79362' + offset = 0 + private_key_file = tmpdir / 'private.pem' + zephyr_hex_file = Path(__file__).parent / 'dummy_zephyr.hex' + hash_file = tmpdir / 'hash.sha512' + message_signature_file = tmpdir / 'zephyr.signature' + output_hex_file = tmpdir / 'output.hex' + + keys_generator = Ed25519KeysGenerator() + keys_generator.write_private_key_pem(private_key_file) + + hash_file.open('wb').write(generate_hash_digest(str(zephyr_hex_file), 'sha512')) + + do_sign.sign_with_ed25519(private_key_file, hash_file, message_signature_file) + + Ed25519SignatureValidator(hashfunc=hashlib.sha512).append_validation_data( + signature_file=message_signature_file, + input_file=zephyr_hex_file, + public_key=keys_generator.public_key, + offset=offset, + output_hex=output_hex_file.open('w'), + output_bin=None, + magic_value=magic_value + ) + + +def test_data_validation_for_signed_hex_file_with_ed25519(tmpdir): + magic_value = '0x281ee6de,0x86518483,79362' + offset = 0 + private_key_file = tmpdir / 'private.pem' + public_key_file = tmpdir / 'public.pem' + zephyr_hex_file = Path(__file__).parent / 'dummy_zephyr.hex' + message_signature_file = tmpdir / 'zephyr.signature' + output_hex_file = tmpdir / 'output.hex' + + keys_generator = Ed25519KeysGenerator() + keys_generator.write_private_key_pem(private_key_file) + keys_generator.write_public_key_pem(public_key_file) + + do_sign.sign_with_ed25519(private_key_file, zephyr_hex_file, message_signature_file) + + # verify signature + Ed25519SignatureValidator().verify( + public_key=keys_generator.public_key, + message_bytes=do_sign.hex_to_binary(str(zephyr_hex_file)), + signature_bytes=message_signature_file.open('rb').read() + ) + + # create validation data + Ed25519SignatureValidator().append_validation_data( + signature_file=message_signature_file, + input_file=zephyr_hex_file, + public_key=keys_generator.public_key, + offset=offset, + output_hex=output_hex_file.open('w'), + output_bin=None, + magic_value=magic_value + ) + # check with CLI command too + assert validation_data_main( + [ + '--algorithm=ed25519', + '--signature', str(message_signature_file), + '--input', str(zephyr_hex_file), + '--public-key', str(public_key_file), + '--offset', offset, + '--output-hex', str(output_hex_file), + '--magic-value', magic_value + ] + ) == 0 diff --git a/scripts/bootloader/validation_data.py b/scripts/bootloader/validation_data.py old mode 100644 new mode 100755 index 44fc41266e0a..1dfbe48e312b --- a/scripts/bootloader/validation_data.py +++ b/scripts/bootloader/validation_data.py @@ -4,106 +4,295 @@ # # SPDX-License-Identifier: LicenseRef-Nordic-5-Clause +from __future__ import annotations -from intelhex import IntelHex - -import hashlib +import abc import argparse +import hashlib import struct -import ecdsa +import sys +from pathlib import Path +from typing import TextIO, BinaryIO + +import ecdsa # type: ignore[import-untyped] +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.serialization import load_pem_public_key +from intelhex import IntelHex # type: ignore[import-untyped] + + +class BaseValidator(abc.ABC): + + def __init__(self, hashfunc=None) -> None: + """ + param hashfunc: hashing function e.g. hashlib.sha256 + """ + self.hashfunc = hashfunc + + def get_hash(self, input_hex: IntelHex) -> bytes: + firmware_bytes = input_hex.tobinstr() + return self.hashfunc(firmware_bytes).digest() + + @abc.abstractmethod + def to_string(self, public_key) -> bytes: + """Serialize the public key to bytes.""" + + @abc.abstractmethod + def verify(self, public_key, signature: bytes, message_bytes: bytes): + """Verify signature. Raise an exception if not valid.""" + + def get_validation_data( + self, + signature_bytes: bytes, + input_hex: IntelHex, + public_key: ecdsa.VerifyingKey | ed25519.Ed25519PublicKey, + magic_value: bytes + ) -> bytes: + hash_bytes = self.get_hash(input_hex) + public_key_bytes = self.to_string(public_key) + + # Will raise an exception if it fails + self.verify(public_key, signature_bytes, hash_bytes) + + validation_bytes = magic_value + validation_bytes += struct.pack('I', input_hex.addresses()[0]) + validation_bytes += hash_bytes + validation_bytes += public_key_bytes + validation_bytes += signature_bytes + return validation_bytes -def get_hash(input_hex): - firmware_bytes = input_hex.tobinstr() - return hashlib.sha256(firmware_bytes).digest() + def append_validation_data( + self, + signature_file: Path, + input_file: Path, + public_key: ecdsa.VerifyingKey | ed25519.Ed25519PublicKey, + offset: int, + output_hex: TextIO, + output_bin: BinaryIO | None, + magic_value: str + ) -> None: + with open(input_file, 'r', encoding='UTF-8') as f: + ih = IntelHex(f) + ih.start_addr = None # OBJCOPY incorrectly inserts x86 specific records, remove the start_addr as it is wrong. + minimum_offset = ((ih.maxaddr() // 4) + 1) * 4 + if offset != 0 and offset < minimum_offset: + raise RuntimeError(f'Incorrect offset, must be bigger than {hex(minimum_offset)}') -def get_validation_data(signature_bytes, input_hex, public_key, magic_value): - hash_bytes = get_hash(input_hex) - public_key_bytes = public_key.to_string() + # Parse comma-separated string of uint32s into hex string. Each is encoded in little-endian byte order + parsed_magic_value = b''.join( + [struct.pack(' bytes: + with open(file, 'rb', encoding=encoding) as f: + data_bytes = f.read() + return data_bytes - minimum_offset = ((ih.maxaddr() // 4) + 1) * 4 - if offset != 0 and offset < minimum_offset: - raise RuntimeError(f'Incorrect offset, must be bigger than {hex(minimum_offset)}') + def _read_text(self, file, encoding=None) -> str: + with open(file, 'r', encoding=encoding) as f: + data = f.read() + return data - # Parse comma-separated string of uint32s into hex string. Each is encoded in little-endian byte order - parsed_magic_value = b''.join([struct.pack(' bytes: + return public_key.to_string() - validation_data_hex.frombytes(validation_data, offset) + def verify(self, public_key: ecdsa.VerifyingKey, signature_bytes: bytes, message_bytes: bytes): + public_key.verify(signature_bytes, message_bytes, hashfunc=self.hashfunc) - ih.merge(validation_data_hex) - ih.write_hex_file(output_hex) - if output_bin: - ih.tofile(output_bin.name, format='bin') +class Ed25519SignatureValidator(BaseValidator): + def to_string(self, public_key) -> bytes: + """Serialize the public key to bytes.""" + public_key_bytes = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + return public_key_bytes -def parse_args(): + def verify(self, public_key: ed25519.Ed25519PublicKey, signature_bytes: bytes, + message_bytes: bytes): + public_key.verify(signature_bytes, message_bytes) + + def get_validation_data( + self, + signature_bytes: bytes, + input_hex: IntelHex, + public_key: ed25519.Ed25519PublicKey, + magic_value: bytes + ) -> bytes: + if self.hashfunc: + return super().get_validation_data( + signature_bytes=signature_bytes, + input_hex=input_hex, + public_key=public_key, + magic_value=magic_value + ) + validation_bytes = magic_value + validation_bytes += struct.pack('I', input_hex.addresses()[0]) + validation_bytes += signature_bytes + + return validation_bytes + + def append_validation_data( + self, + signature_file: Path, + input_file: Path, + public_key: ecdsa.VerifyingKey | ed25519.Ed25519PublicKey, + offset: int, + output_hex: TextIO, + output_bin: BinaryIO | None, + magic_value: str + ) -> None: + if self.hashfunc: + return super().append_validation_data( + signature_file=signature_file, + input_file=input_file, + public_key=public_key, + offset=offset, + output_hex=output_hex, + output_bin=output_bin, + magic_value=magic_value + ) + + with open(input_file, 'r', encoding='UTF-8') as f: + ih = IntelHex(f) + ih.start_addr = None # OBJCOPY incorrectly inserts x86 specific records, remove the start_addr as it is wrong. + + minimum_offset = ((ih.maxaddr() // 4) + 1) * 4 + if offset != 0 and offset < minimum_offset: + raise RuntimeError(f'Incorrect offset, must be bigger than {hex(minimum_offset)}') + + # Parse comma-separated string of uint32s into hex string. Each is encoded in little-endian byte order + parsed_magic_value = b''.join( + [struct.pack(' int: + args = parse_args(argv) - args = parse_args() + if args.algorithm == 'ecdsa': + with open(args.public_key, 'r', encoding='UTF-8') as f: + public_key = ecdsa.VerifyingKey.from_pem(f.read()) + EcdsaSignatureValidator(hashfunc=hashlib.sha256).append_validation_data( + signature_file=args.signature, + input_file=args.input, + public_key=public_key, + offset=args.offset, + output_hex=args.output_hex, + output_bin=args.output_bin, + magic_value=args.magic_value + ) + elif args.algorithm == 'ed25519': + with open(args.public_key, 'rb') as f: + public_key = load_pem_public_key(f.read()) + if args.hash == 'sha512': + hashfunction = hashlib.sha512 + else: + hashfunction = None + Ed25519SignatureValidator(hashfunction).append_validation_data( + signature_file=args.signature, + input_file=args.input, + public_key=public_key, + offset=args.offset, + output_hex=args.output_hex, + output_bin=args.output_bin, + magic_value=args.magic_value + ) + else: + raise SystemExit('Not implemented') - append_validation_data(signature=args.signature.read(), - input_file=args.input, - public_key=ecdsa.VerifyingKey.from_pem(args.public_key.read()), - offset=args.offset, - output_hex=args.output_hex, - output_bin=args.output_bin, - magic_value=args.magic_value) + return 0 if __name__ == '__main__': - main() + sys.exit(main())