diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..bcea93a --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +6f4c6ffaf316f20ecc539f8e437ea9ce629e5cda:actions/sign-firmware/tests/dummy_private_key.pem:private-key:1 diff --git a/actions/build-stm32/Dockerfile b/actions/build-stm32/Dockerfile index d2b33fc..cdff79e 100644 --- a/actions/build-stm32/Dockerfile +++ b/actions/build-stm32/Dockerfile @@ -3,7 +3,7 @@ FROM xanderhendriks/stm32cubeide:15.0 # Tools your script needs RUN apt-get -y update && \ - apt-get -y install curl python3 python3-pycryptodome python3-ecdsa python3-pyelftools python3-numpy git srecord ca-certificates bear cppcheck jq \ + apt-get -y install curl python3 python3-pycryptodome python3-ecdsa python3-pyelftools python3-numpy python3-cryptography python3-intelhex git srecord ca-certificates bear cppcheck jq \ && ln -s $(which python3) /usr/bin/python \ && rm -rf /var/lib/apt/lists/* diff --git a/actions/build-stm32/action.yml b/actions/build-stm32/action.yml index 387de96..a0292d7 100644 --- a/actions/build-stm32/action.yml +++ b/actions/build-stm32/action.yml @@ -20,6 +20,10 @@ inputs: checkout-token: description: 'Token for submodule checkout (defaults to runner token if not provided)' required: false + private-key: + description: 'PEM-encoded Ed25519 private key for firmware signing in the post-build step. Leave empty to fall back to the dummy test key.' + required: false + default: '' runs: using: 'composite' steps: @@ -42,11 +46,16 @@ runs: env: PROJECT_PATH: ${{ inputs.project-path }} PROJECT_TARGET: ${{ inputs.project-target }} + FIRMWARE_SIGNING_PRIVATE_KEY: ${{ inputs.private-key }} run: | - # Mount workspace; pass args expected by your Docker entrypoint + # Mount workspace; pass args expected by your Docker entrypoint. + # FIRMWARE_SIGNING_PRIVATE_KEY is forwarded so the .cproject post-build + # step can sign the linked image. Empty -> sign step falls back to the + # committed dummy key. docker run --rm \ -v "${GITHUB_WORKSPACE}:/workspace" \ -w /workspace \ + -e FIRMWARE_SIGNING_PRIVATE_KEY \ stm32cubeide-builder \ "${PROJECT_PATH}" "${PROJECT_TARGET}" diff --git a/actions/extract-memory-map/action.yml b/actions/extract-memory-map/action.yml new file mode 100644 index 0000000..0a5d544 --- /dev/null +++ b/actions/extract-memory-map/action.yml @@ -0,0 +1,30 @@ +name: "Extract Memory Map" +description: | + Compiles a small C extractor against the calling repo's + app_bootloader_interface.h and exports the slot addresses to + $GITHUB_ENV. The header is the single source of truth. + + Exports: MAIN_APP_START, MAIN_APP_END, MAIN_SIG_START, MAIN_SIG_END, + MAIN_CRC_ADDR, BL_APP_START, BL_APP_END, BL_SIG_START, + BL_SIG_END, BL_CRC_ADDR. + + The header must define EXEC_APP_START_ADDR, APP_FLASH_SIZE, + APP_SIG_START_ADDR, APP_SIG_END_ADDR, APP_CRC_ADDR, and the BOOT_* + equivalents. + +inputs: + include-dir: + description: "Directory containing app_bootloader_interface.h" + required: true + +runs: + using: "composite" + steps: + - name: Compile and run extractor + shell: bash + run: | + set -euo pipefail + gcc -I "${{ inputs.include-dir }}" \ + "$GITHUB_ACTION_PATH/extract.c" \ + -o /tmp/_extract_memmap + /tmp/_extract_memmap | tee -a "$GITHUB_ENV" diff --git a/actions/extract-memory-map/extract.c b/actions/extract-memory-map/extract.c new file mode 100644 index 0000000..3b76493 --- /dev/null +++ b/actions/extract-memory-map/extract.c @@ -0,0 +1,16 @@ +#include +#include "app_bootloader_interface.h" + +int main(void) { + printf("MAIN_APP_START=0x%08X\n", EXEC_APP_START_ADDR); + printf("MAIN_APP_END=0x%08X\n", EXEC_APP_START_ADDR + APP_FLASH_SIZE); + printf("MAIN_SIG_START=0x%08X\n", APP_SIG_START_ADDR); + printf("MAIN_SIG_END=0x%08X\n", APP_SIG_END_ADDR); + printf("MAIN_CRC_ADDR=0x%08X\n", APP_CRC_ADDR); + printf("BL_APP_START=0x%08X\n", BOOTLOADER_START_ADDR); + printf("BL_APP_END=0x%08X\n", BOOTLOADER_START_ADDR + BOOT_FLASH_SIZE); + printf("BL_SIG_START=0x%08X\n", BOOT_SIG_START_ADDR); + printf("BL_SIG_END=0x%08X\n", BOOT_SIG_END_ADDR); + printf("BL_CRC_ADDR=0x%08X\n", BOOT_CRC_ADDR); + return 0; +} diff --git a/actions/sign-firmware/.gitignore b/actions/sign-firmware/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/actions/sign-firmware/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/actions/sign-firmware/action.yml b/actions/sign-firmware/action.yml new file mode 100644 index 0000000..307dc70 --- /dev/null +++ b/actions/sign-firmware/action.yml @@ -0,0 +1,47 @@ +name: "Sign Firmware (Ed25519)" +description: | + Sign an Intel HEX firmware image with Ed25519 and patch the 64-byte + signature into a fixed region at app_end - 68. Does NOT compute CRC -- + run the existing CRC step AFTER this action. + +inputs: + hex-path: + description: "Path to the input Intel HEX file" + required: true + app-start: + description: "App slot start address (e.g. 0x08020000)" + required: true + app-end: + description: "App slot end address, exclusive (e.g. 0x08040000)" + required: true + output-path: + description: "Output HEX path (defaults to in-place modification of hex-path)" + required: false + default: "" + private-key: + description: "PEM-encoded Ed25519 private key contents (pass secrets.FIRMWARE_SIGNING_PRIVATE_KEY)" + required: true + +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Sign firmware + shell: bash + env: + FIRMWARE_SIGNING_PRIVATE_KEY: ${{ inputs.private-key }} + run: | + set -euo pipefail + args=( + --hex "${{ inputs.hex-path }}" + --app-start "${{ inputs.app-start }}" + --app-end "${{ inputs.app-end }}" + ) + if [ -n "${{ inputs.output-path }}" ]; then + args+=(--output "${{ inputs.output-path }}") + fi + sh "$GITHUB_ACTION_PATH/run.sh" "${args[@]}" diff --git a/actions/sign-firmware/run.sh b/actions/sign-firmware/run.sh new file mode 100755 index 0000000..eae8a02 --- /dev/null +++ b/actions/sign-firmware/run.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Entry point for sign_firmware.py that works in three environments: +# 1. CI containers / dev machines where cryptography + intelhex are already +# importable from system python3 -> runs sign_firmware.py directly. +# 2. Dev machines without those modules -> bootstraps a venv at +# $(dirname "$0")/.venv on first run, installs the deps, then runs from +# the venv. Subsequent runs reuse the venv. +# 3. CI runners with no Python at all -> caller must install python3 first; +# this script does not try to install it. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if python3 -c "import cryptography, intelhex" 2>/dev/null; then + exec python3 "$SCRIPT_DIR/sign_firmware.py" "$@" +fi + +VENV="$SCRIPT_DIR/.venv" +if [ ! -x "$VENV/bin/python" ]; then + echo "sign-firmware: bootstrapping venv at $VENV (one-time)" >&2 + python3 -m venv "$VENV" + "$VENV/bin/pip" install --quiet --upgrade pip + "$VENV/bin/pip" install --quiet "cryptography>=41" "intelhex>=2.3" +fi + +exec "$VENV/bin/python" "$SCRIPT_DIR/sign_firmware.py" "$@" diff --git a/actions/sign-firmware/sign_firmware.py b/actions/sign-firmware/sign_firmware.py new file mode 100644 index 0000000..f295500 --- /dev/null +++ b/actions/sign-firmware/sign_firmware.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Sign an Intel HEX firmware image with Ed25519. + +Layout (fixed): + sig region: 64 bytes at app_end - 68 + crc region: 4 bytes at app_end - 4 (NOT touched here; computed by a later step) + sig covers: bytes [app_start, app_end - 68), gaps padded with 0xFF + +Key resolution (in order): + 1. FIRMWARE_SIGNING_PRIVATE_KEY env var (PEM contents) -- CI path + 2. --private-key-file FILE -- local explicit path + 3. --allow-dummy-key -- uses tests/dummy_private_key.pem next to this script + (committed, non-secret, for local pipeline smoke tests only) + +Usage: + python sign_firmware.py \\ + --hex firmware.hex \\ + --app-start 0x08020000 \\ + --app-end 0x08040000 \\ + [--output firmware_signed.hex] \\ + [--private-key-file key.pem | --allow-dummy-key] +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from intelhex import IntelHex + +SIG_LEN = 64 +CRC_LEN = 4 +TRAILER_LEN = SIG_LEN + CRC_LEN # 68 bytes reserved at end of app slot +PAD_BYTE = 0xFF # erased flash state on STM32 + + +class SigningError(RuntimeError): + pass + + +def parse_address(value: str) -> int: + text = value.strip().replace("_", "") + try: + return int(text, 16) if text.lower().startswith("0x") else int(text, 0) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"Invalid address {value!r}") from exc + + +def load_private_key(args: argparse.Namespace) -> Ed25519PrivateKey: + pem = os.environ.get("FIRMWARE_SIGNING_PRIVATE_KEY") + source = "FIRMWARE_SIGNING_PRIVATE_KEY env" + + if not pem and args.private_key_file: + pem = Path(args.private_key_file).read_text() + source = f"file {args.private_key_file}" + elif not pem and args.allow_dummy_key: + dummy = Path(__file__).parent / "tests" / "dummy_private_key.pem" + if not dummy.exists(): + raise SigningError(f"Dummy key not found at {dummy}") + pem = dummy.read_text() + source = "DUMMY KEY (--allow-dummy-key)" + print( + "WARNING: signing with the committed dummy key. " + "DO NOT USE IN PRODUCTION.", + file=sys.stderr, + ) + + if not pem: + raise SigningError( + "No private key provided. Set FIRMWARE_SIGNING_PRIVATE_KEY env, " + "pass --private-key-file, or use --allow-dummy-key for local testing." + ) + + key = serialization.load_pem_private_key(pem.encode(), password=None) + if not isinstance(key, Ed25519PrivateKey): + raise SigningError( + f"Expected an Ed25519 private key, got {type(key).__name__}" + ) + print(f"Using private key from: {source}", file=sys.stderr) + return key + + +def extract_payload(ih: IntelHex, app_start: int, sig_offset: int) -> bytes: + if app_start >= sig_offset: + raise SigningError( + f"app_start (0x{app_start:08X}) must be below sig_offset (0x{sig_offset:08X})" + ) + ih.padding = PAD_BYTE + return bytes(ih.tobinarray(start=app_start, size=sig_offset - app_start)) + + +def patch_signature(ih: IntelHex, sig_offset: int, signature: bytes) -> None: + if len(signature) != SIG_LEN: + raise SigningError(f"Signature must be {SIG_LEN} bytes, got {len(signature)}") + for i, byte in enumerate(signature): + ih[sig_offset + i] = byte + + +def sign_hex( + *, + hex_path: Path, + app_start: int, + app_end: int, + private_key: Ed25519PrivateKey, + output_path: Path | None = None, +) -> Path: + if app_end - app_start <= TRAILER_LEN: + raise SigningError( + f"App slot too small: end-start = {app_end - app_start} bytes, " + f"need > {TRAILER_LEN}" + ) + sig_offset = app_end - TRAILER_LEN + + ih = IntelHex(str(hex_path)) + payload = extract_payload(ih, app_start, sig_offset) + signature = private_key.sign(payload) + patch_signature(ih, sig_offset, signature) + + out = output_path or hex_path + ih.write_hex_file(str(out)) + print( + f"Signed {len(payload)} bytes from 0x{app_start:08X} to 0x{sig_offset:08X}; " + f"signature written at 0x{sig_offset:08X} in {out}", + file=sys.stderr, + ) + return out + + +def verify_signed_hex( + *, + hex_path: Path, + app_start: int, + app_end: int, + public_key: Ed25519PublicKey, +) -> bool: + sig_offset = app_end - TRAILER_LEN + ih = IntelHex(str(hex_path)) + payload = extract_payload(ih, app_start, sig_offset) + signature = bytes(ih.tobinarray(start=sig_offset, size=SIG_LEN)) + try: + public_key.verify(signature, payload) + return True + except InvalidSignature: + return False + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--hex", required=True, type=Path, help="Input Intel HEX file") + parser.add_argument("--app-start", required=True, type=parse_address, help="App slot start address") + parser.add_argument("--app-end", required=True, type=parse_address, help="App slot end address (exclusive)") + parser.add_argument("--output", type=Path, default=None, help="Output hex (default: in-place)") + parser.add_argument("--private-key-file", type=Path, default=None, help="Path to PEM private key") + parser.add_argument("--allow-dummy-key", action="store_true", help="Use committed dummy key for local testing") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + priv = load_private_key(args) + sign_hex( + hex_path=args.hex, + app_start=args.app_start, + app_end=args.app_end, + private_key=priv, + output_path=args.output, + ) + except SigningError as exc: + print(f"sign-firmware: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/actions/sign-firmware/tests/dummy_private_key.pem b/actions/sign-firmware/tests/dummy_private_key.pem new file mode 100644 index 0000000..21f4ffe --- /dev/null +++ b/actions/sign-firmware/tests/dummy_private_key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIONneOHFBbq8a5/pmSWd0Ol3A5kOOHz8jW/5T5RkR+Sr +-----END PRIVATE KEY----- diff --git a/actions/sign-firmware/tests/dummy_public_key.pem b/actions/sign-firmware/tests/dummy_public_key.pem new file mode 100644 index 0000000..4c51a54 --- /dev/null +++ b/actions/sign-firmware/tests/dummy_public_key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAulpl/okOwDXiz5hkCyp1fXSY2vPMOiF0dxjhFMSKIT0= +-----END PUBLIC KEY----- diff --git a/actions/sign-firmware/tests/test_sign_firmware.py b/actions/sign-firmware/tests/test_sign_firmware.py new file mode 100644 index 0000000..68743d2 --- /dev/null +++ b/actions/sign-firmware/tests/test_sign_firmware.py @@ -0,0 +1,244 @@ +"""Tests for sign_firmware.py. + +Run from the action directory: + python -m pytest tests/ +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from intelhex import IntelHex + +ACTION_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ACTION_DIR)) + +import sign_firmware as sf # noqa: E402 + +DUMMY_PRIV = ACTION_DIR / "tests" / "dummy_private_key.pem" +DUMMY_PUB = ACTION_DIR / "tests" / "dummy_public_key.pem" + +APP_START = 0x08020000 +APP_END = 0x08040000 # 128 KiB slot +SIG_OFFSET = APP_END - sf.TRAILER_LEN + + +def _make_synthetic_hex(tmp_path: Path, size: int = 0x1000) -> Path: + """Build a small hex with a recognizable byte pattern at app_start.""" + ih = IntelHex() + for i in range(size): + ih[APP_START + i] = (i * 7 + 13) & 0xFF + path = tmp_path / "fw.hex" + ih.write_hex_file(str(path)) + return path + + +def _load_dummy_public_key(): + return serialization.load_pem_public_key(DUMMY_PUB.read_bytes()) + + +def _load_dummy_private_key(): + return serialization.load_pem_private_key(DUMMY_PRIV.read_bytes(), password=None) + + +def test_sign_and_verify_roundtrip(tmp_path): + hex_path = _make_synthetic_hex(tmp_path) + sf.sign_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + private_key=_load_dummy_private_key(), + ) + assert sf.verify_signed_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + public_key=_load_dummy_public_key(), + ) + + +def test_signature_is_deterministic(tmp_path): + """Ed25519 is deterministic: same key + same message -> same signature.""" + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + dir_a.mkdir() + dir_b.mkdir() + a = _make_synthetic_hex(dir_a) + b = _make_synthetic_hex(dir_b) + + priv = _load_dummy_private_key() + sf.sign_hex(hex_path=a, app_start=APP_START, app_end=APP_END, private_key=priv) + sf.sign_hex(hex_path=b, app_start=APP_START, app_end=APP_END, private_key=priv) + + sig_a = bytes(IntelHex(str(a)).tobinarray(start=SIG_OFFSET, size=sf.SIG_LEN)) + sig_b = bytes(IntelHex(str(b)).tobinarray(start=SIG_OFFSET, size=sf.SIG_LEN)) + assert sig_a == sig_b + + +def test_tampered_payload_fails_verification(tmp_path): + hex_path = _make_synthetic_hex(tmp_path) + sf.sign_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + private_key=_load_dummy_private_key(), + ) + + ih = IntelHex(str(hex_path)) + ih[APP_START + 100] ^= 0x01 + ih.write_hex_file(str(hex_path)) + + assert not sf.verify_signed_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + public_key=_load_dummy_public_key(), + ) + + +def test_tampered_signature_fails_verification(tmp_path): + hex_path = _make_synthetic_hex(tmp_path) + sf.sign_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + private_key=_load_dummy_private_key(), + ) + + ih = IntelHex(str(hex_path)) + ih[SIG_OFFSET + 10] ^= 0xFF + ih.write_hex_file(str(hex_path)) + + assert not sf.verify_signed_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + public_key=_load_dummy_public_key(), + ) + + +def test_crc_region_left_untouched(tmp_path): + """Bytes [app_end - 4, app_end) must not be modified by signing.""" + hex_path = _make_synthetic_hex(tmp_path) + ih = IntelHex(str(hex_path)) + crc_marker = b"\xDE\xAD\xBE\xEF" + for i, b in enumerate(crc_marker): + ih[APP_END - sf.CRC_LEN + i] = b + ih.write_hex_file(str(hex_path)) + + sf.sign_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + private_key=_load_dummy_private_key(), + ) + + ih = IntelHex(str(hex_path)) + written = bytes(ih.tobinarray(start=APP_END - sf.CRC_LEN, size=sf.CRC_LEN)) + assert written == crc_marker + + +def test_payload_padded_with_0xff_for_gaps(tmp_path): + """Sparse hex regions must be treated as 0xFF (erased flash) when signing.""" + ih = IntelHex() + ih[APP_START] = 0xAA + ih[APP_START + 0x800] = 0xBB # leaves a gap + hex_path = tmp_path / "sparse.hex" + ih.write_hex_file(str(hex_path)) + + sf.sign_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + private_key=_load_dummy_private_key(), + ) + + assert sf.verify_signed_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + public_key=_load_dummy_public_key(), + ) + + +def test_cli_with_dummy_key_flag(tmp_path): + hex_path = _make_synthetic_hex(tmp_path) + result = subprocess.run( + [ + sys.executable, + str(ACTION_DIR / "sign_firmware.py"), + "--hex", str(hex_path), + "--app-start", hex(APP_START), + "--app-end", hex(APP_END), + "--allow-dummy-key", + ], + capture_output=True, + text=True, + env={k: v for k, v in os.environ.items() if k != "FIRMWARE_SIGNING_PRIVATE_KEY"}, + ) + assert result.returncode == 0, result.stderr + assert "DUMMY KEY" in result.stderr + assert sf.verify_signed_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + public_key=_load_dummy_public_key(), + ) + + +def test_cli_fails_without_any_key(tmp_path): + hex_path = _make_synthetic_hex(tmp_path) + result = subprocess.run( + [ + sys.executable, + str(ACTION_DIR / "sign_firmware.py"), + "--hex", str(hex_path), + "--app-start", hex(APP_START), + "--app-end", hex(APP_END), + ], + capture_output=True, + text=True, + env={k: v for k, v in os.environ.items() if k != "FIRMWARE_SIGNING_PRIVATE_KEY"}, + ) + assert result.returncode != 0 + assert "No private key" in result.stderr + + +def test_cli_with_env_var(tmp_path): + hex_path = _make_synthetic_hex(tmp_path) + env = {k: v for k, v in os.environ.items() if k != "FIRMWARE_SIGNING_PRIVATE_KEY"} + env["FIRMWARE_SIGNING_PRIVATE_KEY"] = DUMMY_PRIV.read_text() + result = subprocess.run( + [ + sys.executable, + str(ACTION_DIR / "sign_firmware.py"), + "--hex", str(hex_path), + "--app-start", hex(APP_START), + "--app-end", hex(APP_END), + ], + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0, result.stderr + assert sf.verify_signed_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_END, + public_key=_load_dummy_public_key(), + ) + + +def test_app_slot_too_small_rejected(tmp_path): + hex_path = _make_synthetic_hex(tmp_path, size=16) + with pytest.raises(sf.SigningError, match="too small"): + sf.sign_hex( + hex_path=hex_path, + app_start=APP_START, + app_end=APP_START + 32, + private_key=_load_dummy_private_key(), + )