Firmware-Signierung unter Zephyr: So einfach geht's
Der Cyber Resilience Act verlangt in Anhang I, dass Produkte die Integrität ihrer Programme gegen unbefugte Manipulation schützen. Für den Startvorgang heisst das: Das Gerät führt nur Firmware aus, die vom Hersteller signiert wurde.
Der Aufwand dafür ist kleiner, als die Anforderung vermuten lässt.
Als Beispiel dient ein minimaler Aufbau auf einem ST Nucleo L552ZE-Q unter Zephyr 4.4.2.
Die Konfiguration
Die Signaturkonfiguration steht in app/sysbuild.conf. Sysbuild baut MCUboot und die Anwendung gemeinsam, damit beide zum selben Schlüsselpaar passen: Der Bootloader bekommt den Public Key eingebaut, imgtool signiert mit dem Private Key.
SB_CONFIG_BOOTLOADER_MCUBOOT=y
# Der Standardwert ist RSA, ECDSA muss ausdrücklich gewählt werden.
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
# Ohne diese Zeile verwendet der Build MCUboots eigenen Demo-Schlüssel.
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="${APP_DIR}/../keys/root-ec-p256.pem"
SB_CONFIG_BOOT_SIGNATURE_KEY_FILEist die wichtigste Zeile. Fehlt sie, greift ein Standardwert:root-ec-p256.pemaus dem MCUboot-Repository, ein Schlüssel, den jeder auf GitHub herunterladen kann. Der Build läuft durch, das Gerät bootet, die Signatur ist gültig. Es sieht in jeder Hinsicht korrekt aus. Nur vertraut das Produkt dann jedem, der diese Datei findet. Wer den Block oben übernimmt, muss sie auf einen selbst erzeugten Schlüssel zeigen lassen.
Dazu kommt eine Zeile in app/prj.conf:
CONFIG_BOOTLOADER_MCUBOOT=y
Sie linkt die Anwendung an die Adresse von slot0 und gibt ihr einen MCUboot-Header, statt sie an den Anfang des Flash zu legen, wo der Bootloader selbst liegt.
Das ist die gesamte Konfiguration.
Die Anwendung
Die Anwendung selbst tut nichts Besonderes. Sie liest den MCUboot-Header ihres eigenen Slots aus und gibt die signierte Version aus:
#define SLOT0_OFFSET DT_REG_ADDR(DT_NODELABEL(slot0_partition))
int main(void)
{
struct mcuboot_img_header hdr;
printk("\n*** Secure boot sample ***\n");
printk("Running from slot0 at offset 0x%06x\n",
(unsigned int)SLOT0_OFFSET);
if (boot_read_bank_header(PARTITION_ID(slot0_partition),
&hdr, sizeof(hdr)) == 0) {
printk("Image version %u.%u.%u\n",
hdr.h.v1.sem_ver.major,
hdr.h.v1.sem_ver.minor,
hdr.h.v1.sem_ver.revision);
}
return 0;
}
Die ausgegebene Version stammt aus dem signierten Bereich des Images. Sie ist damit nicht nur eine Anzeige, sondern belegt, welches signierte Image tatsächlich läuft.
Wie das aussieht, wenn es funktioniert
Nach dem Flashen meldet sich zuerst MCUboot, prüft die Signatur und springt an die Anwendung:
*** Booting MCUboot v2.4.0 ***
I: Starting bootloader
I: Primary image: magic=unset, swap_type=0x1, copy_done=0x3, image_ok=0x3
I: Boot source: none
I: Bootloader chainload address offset: 0x10000
I: Image version: v1.0.0
*** Booting Zephyr OS build v4.4.2 ***
*** Secure boot sample ***
Running from slot0 at offset 0x010000
Image version 1.0.0
Die ersten sechs Zeilen kommen von MCUboot, die letzten drei aus dem main() oben.
Vor der Ausgabe der Zeile chainload address offset: 0x10000 findet die eigentliche Prüfung statt. MCUboot berechnet einen Hash über das Image in slot0 und vergleicht ihn mit der Signatur, die beim Bauen an das Image angehängt wurde. Dafür verwendet es den Public Key, der im Bootloader einkompiliert ist. Erst wenn das zusammenpasst, springt es an die Anwendung.
Und wenn ein einziges Bit kippt
Jetzt dasselbe Image, mit einem gekippten Byte, sonst unverändert:
*** Booting MCUboot v2.4.0 ***
I: Starting bootloader
I: Primary image: magic=unset, swap_type=0x1, copy_done=0x3, image_ok=0x3
I: Boot source: none
E: Image in the primary slot is not valid!
E: Unable to find bootable image
Das Flash-Layout
Mit Bootloader liegen zwei Programme im Flash statt einem, dazu ein Bereich, in den ein künftiges Update geschrieben wird.
Dieses Board bringt für das verwendete Target keine passende Aufteilung mit, sie muss also selbst geschrieben werden:
&flash0 {
partitions {
compatible = "fixed-partitions";
#address-cells = <1>;
#size-cells = <1>;
boot_partition: partition@0 {
label = "mcuboot";
reg = <0x00000000 DT_SIZE_K(64)>;
read-only;
};
slot0_partition: partition@10000 {
label = "image-0";
reg = <0x00010000 DT_SIZE_K(192)>;
};
slot1_partition: partition@40000 {
label = "image-1";
reg = <0x00040000 DT_SIZE_K(192)>;
};
storage_partition: partition@70000 {
label = "storage";
reg = <0x00070000 DT_SIZE_K(64)>;
};
};
};
Entscheidend sind die Label: boot_partition, slot0_partition und slot1_partition sind die Namen, unter denen MCUboot und das Build-System diese Bereiche erwarten. An ihnen hängt, dass die Anwendung nach slot0 gelinkt wird und der Bootloader dort nach dem signierten Image sucht. read-only an der Bootloader-Partition hindert die laufende Anwendung daran, den Vertrauensanker zu überschreiben. Das ist ein Software-Riegel und keine Hardwaresperre: Ein angestecktes ST-Link überschreibt den Bootloader trotzdem. Echte Unveränderlichkeit braucht Schreibschutz-Optionsbytes, die hier nicht gesetzt sind.
Der zweite Slot wird hier nie beschrieben, gehört aber trotzdem dazu. MCUboot erwartet ihn, und ein Produkt braucht ihn. Mit nur einem Slot löscht ein Update die einzige funktionierende Firmware, und ein Stromausfall zur falschen Sekunde hinterlässt ein Gerät, das nicht mehr startet.
Bleibt die Frage, wer diese Datei liest. MCUboot und die Anwendung sind zwei getrennte Zephyr-Images, nicht ein Build mit einem Bootloader-Anteil. Beide müssen sich über die Aufteilung einig sein: MCUboot muss wissen, wo es das signierte Image sucht, und die Anwendung muss genau dorthin gelinkt werden. Deshalb liegt das Layout in einer eigenen Datei und wird von beiden Seiten eingebunden:
app/boards/<board>.overlay → #include "../../dts/secure-boot-partitions.dtsi"
app/sysbuild/mcuboot.overlay → #include "../../dts/secure-boot-partitions.dtsi"
Wer es nur im Overlay der Anwendung definiert, bekommt undefined node label 'boot_partition'. Die Fehlermeldung zeigt auf eine Datei im MCUboot-Baum und nicht auf die eigene Konfiguration.
Was beim Signieren passiert
Signiert wird mit imgtool, einem Skript aus dem MCUboot-Repository. Es hängt an das fertige Binary einen Trailer aus Type-Length-Value-Feldern an: einen Hash über das Image, die Signatur über diesen Hash und die Versionsnummer.
Die Versionsnummer liegt innerhalb des signierten Bereichs. Sie lässt sich also nicht ändern, ohne die Signatur ungültig zu machen. Das ist die Grundlage für Rollback-Schutz.
Der eigentliche Punkt ist die Asymmetrie. Auf dem Gerät liegt nur der Public Key, einkompiliert im Bootloader. Mit ihm lässt sich eine Signatur prüfen, aber keine erzeugen. Wer das Gerät auseinandernimmt und den Flash ausliest, findet nichts, womit sich eigene Firmware signieren liesse.
Signieren, ohne den Private Key auf dem Laptop zu haben
Womit sich die Frage verschiebt: Wo liegt der Private Key? Auf einem Entwicklungsrechner ist er einen kompromittierten Laptop davon entfernt, Schadsoftware zu signieren, der anschliessend jedes Gerät im Feld vertraut.
Dagegen helfen zwei Mechanismen.
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE akzeptiert eine PEM-Datei mit nur dem Public Key. Der Bootloader-Build braucht den Private Key nie, denn er baut nur den Prüfer.
Und imgtool beherrscht abgesetztes Signieren. --vector-to-sign gibt den zu signierenden Digest aus, --fix-sig fügt eine extern erzeugte Signatur wieder ein. Damit kann ein HSM oder ein Cloud-KMS signieren, ohne den Private Key je zu exportieren.
Zephyr erlaubt zusätzlich, den Signierschritt über -DSIGNING_SCRIPT vollständig durch ein eigenes Skript zu ersetzen. Die Public-Key-Datei und das eigene Signierskript sind in Zephyrs Dokumentation zum Signieren beschrieben.
Signiert heisst nicht isoliert
Damit ist bewiesen, dass die Firmware echt ist. Über das, was sie danach tut, sagt das nichts.
Auch echte, korrekt signierte Firmware hat Schwachstellen - zum Beispiel ein Parser, der eine überlange Eingabe nicht abfängt. Und ohne Isolation läuft sie mit vollem Zugriff auf den ganzen Chip: Jeder Schlüssel und jedes Gerätegeheimnis liegt im selben Adressraum wie die ausgenutzte Schwachstelle.
Die Antwort darauf ist Isolation zur Laufzeit, auf dieser MCU also TrustZone mit TF-M. Die nicht-sichere Anwendung darf einen sicheren Dienst aus dem sicheren Bereich nutzen. Den Speicher dahinter lesen darf sie nicht:
[ok] Secure service reachable: got random bytes a444269290d47603
(generated in the Secure world, not here)
[test] Now reading Secure flash at 0x0c014000 ...
FATAL ERROR: HardFault
Genau derselbe Zugriff gelingt in der signierten, aber nicht isolierten Variante klaglos.
Der Aufbau dafür ist ein anderer: eigenes Board-Target, eigener Bootloader, eigener Umgang mit Schlüsseln. Das würde den Rahmen dieses Artikels sprengen - bei Interesse behandle ich es in einem eigenen Beitrag.
Quellen
- Verordnung (EU) 2024/2847 (Cyber Resilience Act), EUR-Lex, massgeblich ist Anhang I
- Zephyr: Signing Binaries
- Zephyr: MCUboot mit sysbuild
- MCUboot: imgtool
- Zephyr: TF-M-Integration
- Zephyr: Getting Started Guide
Alle Konsolenausgaben stammen von einem Nucleo L552ZE-Q unter Zephyr 4.4.2.
Entstanden unter Mitwirkung von KI. Die technischen Aussagen beruhen auf eigener Recherche; der gezeigte Code wurde auf echter Hardware verifiziert.
Dieser Beitrag ist eine technische Einordnung und keine Rechtsberatung. Verbindlich sind der Verordnungstext und die Leitlinien der zuständigen Behörden.