diff --git a/docs/Signing.md b/docs/Signing.md index 3e3377b6ae..7c195211f1 100644 --- a/docs/Signing.md +++ b/docs/Signing.md @@ -274,6 +274,17 @@ Provides a value to be set with a custom tag is 65524 bytes. Unlike `--custom-tlv-buffer`, the value is not passed on the command line, so large binary values are not subject to the OS argument length limits. + * `--custom-tlv-pubkey-der tag filename`: Adds a TLV entry to the manifest header, + corresponding to the type identified by `tag`, with the value extracted from the + DER-encoded public key (SubjectPublicKeyInfo) in `filename`. The tag is a 16-bit + number. Valid tags are in the range between 0x0030 and 0xFEFE. The key algorithm is + detected automatically (ECC, Ed25519, Ed448 or RSA) and the stored value uses the + same format as the wolfBoot keystore: the raw `X||Y` point coordinates for ECC, the + raw public key bytes for Ed25519/Ed448, and the DER-encoded public key for RSA. + This avoids a manual ASN.1-stripping step before `--custom-tlv-file` when embedding + an application-level public key (e.g. for verifying payloads at runtime, or for key + rotation) in the signed manifest. + The 65524-byte maximum is the largest TLV value the wolfBoot header parser can walk past when locating the fields that follow it, such as the signature. diff --git a/tools/keytools/sign.c b/tools/keytools/sign.c index cae6099b64..64a2913089 100644 --- a/tools/keytools/sign.c +++ b/tools/keytools/sign.c @@ -2706,6 +2706,162 @@ uint64_t arg2num(const char *arg, size_t len) return ret; } +/* Load a DER-encoded public key (SubjectPublicKeyInfo), detect the algorithm + * and extract the public key in the same format used by the keystore: + * X||Y for ECC, raw bytes for Ed25519/Ed448, public key DER for RSA. + * On success stores a malloc'd buffer in *out and its size in *out_sz. + */ +static int extract_pubkey_from_der(const char *fname, uint8_t **out, + uint16_t *out_sz) +{ + FILE *f; + long fsz; + size_t rd; + uint8_t *der; + uint8_t *buf = NULL; + int len = -1; + const char *ktype = NULL; + word32 idx; + + f = fopen(fname, "rb"); + if (f == NULL) { + fprintf(stderr, "Cannot open public key DER file %s: %s\n", + fname, strerror(errno)); + return -1; + } + fseek(f, 0, SEEK_END); + fsz = ftell(f); + fseek(f, 0, SEEK_SET); + if ((fsz <= 0) || (fsz > (long)MAX_TLV_LEN)) { + fprintf(stderr, "Invalid public key DER file size %ld: %s\n", + fsz, fname); + fclose(f); + return -1; + } + der = malloc((size_t)fsz); + if (der == NULL) { + fprintf(stderr, "Error malloc for public key DER %ld\n", fsz); + fclose(f); + return -1; + } + rd = fread(der, 1, (size_t)fsz, f); + fclose(f); + if (rd != (size_t)fsz) { + fprintf(stderr, "Error reading public key DER file %s\n", fname); + free(der); + return -1; + } +#ifdef HAVE_ECC + if (len < 0) { + ecc_key ecc; + if (wc_ecc_init(&ecc) == 0) { + idx = 0; + if (wc_EccPublicKeyDecode(der, &idx, &ecc, (word32)fsz) == 0) { + uint8_t qx[MAX_ECC_BYTES], qy[MAX_ECC_BYTES]; + word32 qxsz = sizeof(qx), qysz = sizeof(qy); + if (wc_ecc_export_public_raw(&ecc, qx, &qxsz, qy, &qysz) + == 0) { + buf = malloc(qxsz + qysz); + if (buf != NULL) { + memcpy(buf, qx, qxsz); + memcpy(buf + qxsz, qy, qysz); + len = (int)(qxsz + qysz); + ktype = "ECC"; + } + } + } + wc_ecc_free(&ecc); + } + } +#endif +#ifdef HAVE_ED25519 + if (len < 0) { + ed25519_key ed; + if (wc_ed25519_init(&ed) == 0) { + idx = 0; + if (wc_Ed25519PublicKeyDecode(der, &idx, &ed, (word32)fsz) == 0) { + word32 osz = ED25519_PUB_KEY_SIZE; + buf = malloc(osz); + if (buf != NULL) { + if (wc_ed25519_export_public(&ed, buf, &osz) == 0) { + len = (int)osz; + ktype = "Ed25519"; + } else { + free(buf); + buf = NULL; + } + } + } + wc_ed25519_free(&ed); + } + } +#endif +#ifdef HAVE_ED448 + if (len < 0) { + ed448_key ed4; + if (wc_ed448_init(&ed4) == 0) { + idx = 0; + if (wc_Ed448PublicKeyDecode(der, &idx, &ed4, (word32)fsz) == 0) { + word32 osz = ED448_PUB_KEY_SIZE; + buf = malloc(osz); + if (buf != NULL) { + if (wc_ed448_export_public(&ed4, buf, &osz) == 0) { + len = (int)osz; + ktype = "Ed448"; + } else { + free(buf); + buf = NULL; + } + } + } + wc_ed448_free(&ed4); + } + } +#endif +#ifndef NO_RSA + if (len < 0) { + RsaKey rsa; + if (wc_InitRsaKey(&rsa, NULL) == 0) { + idx = 0; + if (wc_RsaPublicKeyDecode(der, &idx, &rsa, (word32)fsz) == 0) { + int dsz = wc_RsaPublicKeyDerSize(&rsa, 1); + if (dsz > 0) { + buf = malloc((size_t)dsz); + if (buf != NULL) { + len = wc_RsaKeyToPublicDer(&rsa, buf, (word32)dsz); + if (len > 0) { + ktype = "RSA"; + } else { + free(buf); + buf = NULL; + len = -1; + } + } + } + } + wc_FreeRsaKey(&rsa); + } + } +#endif + free(der); + if ((len <= 0) || (buf == NULL)) { + fprintf(stderr, "Unable to parse public key DER file %s " + "(tried ECC, Ed25519, Ed448, RSA)\n", fname); + return -1; + } + if (len > (int)MAX_TLV_LEN) { + fprintf(stderr, "extracted public key too big: %d bytes (max %u): " + "%s\n", len, MAX_TLV_LEN, fname); + free(buf); + return -1; + } + printf("Custom TLV: imported %s public key from %s (%d bytes)\n", + ktype, fname, len); + *out = buf; + *out_sz = (uint16_t)len; + return 0; +} + static void set_signature_sizes(int secondary) { uint32_t *sz = &CMD.signature_sz; @@ -3377,6 +3533,33 @@ int main(int argc, char** argv) } CMD.custom_tlvs++; i += 2; + } else if (strcmp(argv[i], "--custom-tlv-pubkey-der") == 0) { + int p = CMD.custom_tlvs; + uint16_t tag; + if (p >= MAX_CUSTOM_TLVS) { + fprintf(stderr, "Too many custom TLVs.\n"); + exit(16); + } + if (argc < (i + 3)) { + fprintf(stderr, "Invalid custom TLV fields. \n"); + exit(16); + } + tag = (uint16_t)arg2num(argv[i + 1], 2); + if (tag < 0x0030) { + fprintf(stderr, "Invalid custom tag: %s\n", argv[i + 1]); + exit(16); + } + if ( ((tag & 0xFF00) == 0xFF00) || ((tag & 0xFF) == 0xFF) ) { + fprintf(stderr, "Invalid custom tag: %s\n", argv[i + 1]); + exit(16); + } + if (extract_pubkey_from_der(argv[i + 2], + &CMD.custom_tlv[p].buffer, &CMD.custom_tlv[p].len) != 0) { + exit(16); + } + CMD.custom_tlv[p].tag = tag; + CMD.custom_tlvs++; + i += 2; } else if (strcmp(argv[i], "--cert-chain") == 0) { if (argc <= (i + 1)) { diff --git a/tools/unit-tests/Makefile b/tools/unit-tests/Makefile index 5374d28a80..5ba9d7efa1 100644 --- a/tools/unit-tests/Makefile +++ b/tools/unit-tests/Makefile @@ -130,6 +130,7 @@ run: $(TESTS) python3 unit-sign-delta-cert-inv-off.py || exit 1 python3 unit-sign-custom-tlv-le.py || exit 1 python3 unit-sign-custom-tlv-large.py || exit 1 + python3 unit-sign-custom-tlv-pubkey-der.py || exit 1 WOLFCRYPT_SRC:=$(WOLFBOOT_LIB_WOLFSSL)/wolfcrypt/src/sha.c \ diff --git a/tools/unit-tests/unit-sign-custom-tlv-pubkey-der.py b/tools/unit-tests/unit-sign-custom-tlv-pubkey-der.py new file mode 100644 index 0000000000..c1173334d0 --- /dev/null +++ b/tools/unit-tests/unit-sign-custom-tlv-pubkey-der.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +# unit-sign-custom-tlv-pubkey-der.py +# +# Tests --custom-tlv-pubkey-der in the C sign tool (tools/keytools/sign.c): +# a DER SubjectPublicKeyInfo is parsed and the public key is stored as a +# custom TLV in the keystore format (X||Y for ECC, raw bytes for +# Ed25519/Ed448, public key DER for RSA). Also checks that a SEC1 EC +# private key file embeds only the public point and never the private +# scalar, and that unparseable, PKCS#8 private, empty or missing files +# are rejected. +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of wolfBoot. +# +# wolfBoot is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# wolfBoot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +import os +import struct +import subprocess +import sys +import tempfile + +HDR_PADDING = 0xFF + +TAG_ECC256 = 0x0040 +TAG_ECC521 = 0x0041 +TAG_ED25519 = 0x0042 +TAG_ED448 = 0x0043 +TAG_RSA = 0x0044 +TAG_SEC1 = 0x0045 + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(THIS_DIR, "..", "..")) +SIGN = os.path.join(ROOT, "tools", "keytools", "sign") + +failures = [] + + +def skip(msg): + print("SKIP unit-sign-custom-tlv-pubkey-der: " + msg) + sys.exit(0) + + +def fail(msg): + failures.append(msg) + + +def parse_tlvs(data, scan_end): + """Walk the header like wolfBoot_find_header(): {tag: value bytes}.""" + tlvs = {} + p = 8 # skip 4-byte magic + 4-byte image size + while p + 4 <= scan_end: + htype = data[p] | (data[p + 1] << 8) + if htype == 0: + break + if data[p] == HDR_PADDING or (p & 1) != 0: + p += 1 + continue + length = data[p + 2] | (data[p + 3] << 8) + if p + 4 + length > scan_end: + break + if htype not in tlvs: + tlvs[htype] = bytes(data[p + 4:p + 4 + length]) + p += 4 + length + return tlvs + + +def ensure_sign(): + if os.path.exists(SIGN): + return True + try: + subprocess.run(["make", "sign"], + cwd=os.path.join(ROOT, "tools", "keytools"), + check=True, capture_output=True, text=True) + except (subprocess.CalledProcessError, OSError): + return False + return os.path.exists(SIGN) + + +def make_ed25519_key(path): + """Write a 64-byte raw ed25519 key (seed + public) as expected by sign.""" + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import \ + Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + except Exception: + return False + seed = b"\x42" * 32 + sk = Ed25519PrivateKey.from_private_bytes(seed) + pub = sk.public_key().public_bytes(serialization.Encoding.Raw, + serialization.PublicFormat.Raw) + with open(path, "wb") as f: + f.write(seed + pub) + return True + + +def run_sign(args, image, key, version): + cmd = [SIGN, "--ed25519", "--sha256"] + args + [image, key, version] + return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True) + + +def signed_name(image, version): + return image.replace(".bin", "_v%s_signed.bin" % version) + + +def make_image(path, payload): + with open(path, "wb") as f: + f.write(payload) + + +def check_signed_layout(name, signed, payload): + """Return (data, header_size) or None; header must be a power of two + holding the payload right after it.""" + with open(signed, "rb") as f: + data = f.read() + hdr = len(data) - len(payload) + if hdr <= 0 or (hdr & (hdr - 1)) != 0: + fail("%s: file size %d - payload %d = %d is not a power-of-two " + "header" % (name, len(data), len(payload), hdr)) + return None + if data[hdr:] != payload: + fail("%s: payload is not stored at header size %d" % (name, hdr)) + return None + return data, hdr + + +def check_value(name, tlvs, tag, expected): + got = tlvs.get(tag) + if got is None: + fail("%s: tag 0x%04x not found in header" % (name, tag)) + elif got != expected: + fail("%s: tag 0x%04x value mismatch (%d bytes, want %d)" % + (name, tag, len(got), len(expected))) + + +def expect_reject(name, args, image, key, version, needle): + signed = signed_name(image, version) + if os.path.exists(signed): + os.unlink(signed) + r = run_sign(args, image, key, version) + if r.returncode == 0: + fail("%s: sign succeeded, expected rejection" % name) + return + if needle not in r.stderr: + fail("%s: expected '%s' in stderr, got: %s" % + (name, needle, r.stderr.strip()[:200])) + if os.path.exists(signed): + fail("%s: rejected sign still produced an output image" % name) + + +def ecc_xy(pub, size): + n = pub.public_numbers() + return n.x.to_bytes(size, "big") + n.y.to_bytes(size, "big") + + +def main(): + if not ensure_sign(): + skip("could not build tools/keytools/sign") + + try: + from cryptography.hazmat.primitives.asymmetric import (ec, rsa, + ed25519, ed448) + from cryptography.hazmat.primitives.serialization import (Encoding, + PublicFormat, PrivateFormat, NoEncryption) + except Exception: + skip("python cryptography module not available") + + def spki(pub): + return pub.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + + def raw(pub): + return pub.public_bytes(Encoding.Raw, PublicFormat.Raw) + + with tempfile.TemporaryDirectory() as work: + key = os.path.join(work, "priv.der") + if not make_ed25519_key(key): + skip("python cryptography module not available") + + payload = bytes((i * 7) & 0xFF for i in range(2048)) + + # Control: a plain sign must work, or the environment is broken and + # every other failure below would be misleading. + control = os.path.join(work, "control.bin") + make_image(control, payload) + r = run_sign([], control, key, "1") + if r.returncode != 0 or not os.path.exists(signed_name(control, "1")): + skip("control sign failed: " + r.stderr.strip()) + + # One key per supported algorithm, all embedded in a single image. + k_ecc256 = ec.generate_private_key(ec.SECP256R1()) + k_ecc521 = ec.generate_private_key(ec.SECP521R1()) + k_ed25519 = ed25519.Ed25519PrivateKey.generate() + k_ed448 = ed448.Ed448PrivateKey.generate() + k_rsa = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + keys = { + "ecc256.der": spki(k_ecc256.public_key()), + "ecc521.der": spki(k_ecc521.public_key()), + "ed25519.der": spki(k_ed25519.public_key()), + "ed448.der": spki(k_ed448.public_key()), + "rsa.der": spki(k_rsa.public_key()), + } + for fname, der in keys.items(): + with open(os.path.join(work, fname), "wb") as f: + f.write(der) + + expected = { + TAG_ECC256: ecc_xy(k_ecc256.public_key(), 32), + TAG_ECC521: ecc_xy(k_ecc521.public_key(), 66), + TAG_ED25519: raw(k_ed25519.public_key()), + TAG_ED448: raw(k_ed448.public_key()), + # RSA has no raw form: the TLV holds the public key DER, which + # wolfCrypt re-encodes to the same canonical SPKI bytes. + TAG_RSA: keys["rsa.der"], + } + + image = os.path.join(work, "pubkeys.bin") + make_image(image, payload) + r = run_sign(["--custom-tlv-pubkey-der", hex(TAG_ECC256), + os.path.join(work, "ecc256.der"), + "--custom-tlv-pubkey-der", hex(TAG_ECC521), + os.path.join(work, "ecc521.der"), + "--custom-tlv-pubkey-der", hex(TAG_ED25519), + os.path.join(work, "ed25519.der"), + "--custom-tlv-pubkey-der", hex(TAG_ED448), + os.path.join(work, "ed448.der"), + "--custom-tlv-pubkey-der", hex(TAG_RSA), + os.path.join(work, "rsa.der")], + image, key, "1") + if r.returncode != 0: + fail("pubkeys: sign failed: " + r.stderr.strip()[:200]) + else: + res = check_signed_layout("pubkeys", signed_name(image, "1"), + payload) + if res: + data, hdr = res + tlvs = parse_tlvs(data, hdr) + for tag, val in expected.items(): + check_value("pubkeys", tlvs, tag, val) + + # A SEC1 EC private key file is accepted (wolfCrypt extracts the + # public point), but the private scalar must never reach the image. + sec1 = os.path.join(work, "ecc256-sec1-priv.der") + with open(sec1, "wb") as f: + f.write(k_ecc256.private_bytes(Encoding.DER, + PrivateFormat.TraditionalOpenSSL, NoEncryption())) + scalar = k_ecc256.private_numbers().private_value.to_bytes(32, "big") + image = os.path.join(work, "sec1.bin") + make_image(image, payload) + r = run_sign(["--custom-tlv-pubkey-der", hex(TAG_SEC1), sec1], + image, key, "1") + if r.returncode != 0: + fail("sec1: sign failed: " + r.stderr.strip()[:200]) + else: + res = check_signed_layout("sec1", signed_name(image, "1"), + payload) + if res: + data, hdr = res + check_value("sec1", parse_tlvs(data, hdr), TAG_SEC1, + expected[TAG_ECC256]) + if scalar in data: + fail("sec1: private scalar leaked into signed image") + + # Rejections. + image = os.path.join(work, "rej.bin") + make_image(image, payload) + + garbage = os.path.join(work, "garbage.der") + with open(garbage, "wb") as f: + f.write(bytes((i * 89 + 17) & 0xFF for i in range(100))) + expect_reject("reject-garbage", + ["--custom-tlv-pubkey-der", hex(TAG_ECC256), garbage], + image, key, "1", "Unable to parse") + + pkcs8 = os.path.join(work, "ecc256-pkcs8-priv.der") + with open(pkcs8, "wb") as f: + f.write(k_ecc256.private_bytes(Encoding.DER, PrivateFormat.PKCS8, + NoEncryption())) + expect_reject("reject-pkcs8-private", + ["--custom-tlv-pubkey-der", hex(TAG_ECC256), pkcs8], + image, key, "1", "Unable to parse") + + empty = os.path.join(work, "empty.der") + open(empty, "wb").close() + expect_reject("reject-empty", + ["--custom-tlv-pubkey-der", hex(TAG_ECC256), empty], + image, key, "1", "Invalid public key DER file size") + + expect_reject("reject-missing", + ["--custom-tlv-pubkey-der", hex(TAG_ECC256), + os.path.join(work, "does-not-exist.der")], + image, key, "1", "Cannot open") + + expect_reject("reject-bad-tag", + ["--custom-tlv-pubkey-der", "0x0001", + os.path.join(work, "ecc256.der")], + image, key, "1", "Invalid custom tag") + + if failures: + for msg in failures: + print("FAIL unit-sign-custom-tlv-pubkey-der: " + msg) + sys.exit(1) + + print("unit-sign-custom-tlv-pubkey-der: OK") + sys.exit(0) + + +if __name__ == "__main__": + main()