48а. Firmware signing: the bootloader checks who built it
The bootloader from section 48 checks the image with CRC-32, and that protects only against accidental corruption. Anyone who changes the firmware can recompute the CRC in a second, and the bootloader will dutifully start it. To make the device start only your firmware, you sign it: on the computer with a private key, and in the bootloader you verify it with the public key. A signature cannot be forged without the private key, and the private key is not in the device at all.
This section is an experiment with the mbedTLS 3.6.4 library from the pack: an ECDSA signature on the
P-256 curve with a SHA-256 hash, five attempts to fool the bootloader, a cross-check
with another library, and the size of signature verification in Cortex-M4 firmware.
The programs are in _verify/sboot48a/. The check is _verify/sboot48a_check.py;
the verification time is in _verify/fixw2a0/sig48 and _verify/fixw2a0_check.py.
Terms used in the documentation
Term Meaning digital signature a digital signature private key, public key the private key and the public key ECDSA, curve P-256 (secp256r1) a signature on the elliptic curve P-256 message digest, SHA-256 a hash (digest), SHA-256 deterministic ECDSA (RFC 6979) a deterministic signature: the same data give the same signature anti-rollback, security counter protection against rolling back to an old version root of trust the root of trust: what checks first and never changes itself chain of trust the chain of trust
48а.1 The scheme
| Where | What it holds | What it does |
|---|---|---|
| the developer's computer or the build server | private key (32 bytes) | computes the SHA-256 of the image, signs the hash |
| the firmware image | header + body + signature (64 bytes) | travels to the device by any route |
| the bootloader in the device | public key (65 bytes) and the lowest allowed version | computes the hash, verifies the signature, checks the version |
The key point: the header is signed too. Otherwise the version, size and start address could be replaced without touching the signed body.
The public key is not a secret; you can show it to everyone. The secret is the private key, and it must get neither into the device nor into the repository.
48а.2 Experiment: a signature and five attempts to cheat
The image: a header {magic FWSG, version 3, size} and 4096 bytes of body, in total
4108 bytes. The key is derived deterministically (SHA-256 of a string; this is done
only in the experiment so that the run is repeatable; a product key is generated from a real
random source and kept outside the repository), and the signature
follows RFC 6979, where the random number is derived from the key and the hash, so the run
gives the same bytes every time:
sha256=698c2f41db06f429f74bbcd60f5c2b38c00535e9b1062e0f719be752087f12ce
signature=c38bdb835bfe9f6cd4356d36ad82e5506097c047ea1600ae51cfbdb664e3d8bd
bc345987fa0c4e9884f892ce87f659ffbc70c2ab8e1c1112070e18c90e8e3865
The check in the bootloader; the order matters:
if (h.magic != 0x47535746u)
return BAD_MAGIC;
if (h.size > slot - sizeof h) /* размер ещё не проверен подписью: сначала граница области */
return BAD_SIZE;
uint8_t hash[32];
mbedtls_sha256(img, sizeof h + h.size, hash, 0);
then mbedtls_ecdsa_verify and only after that the version comparison.
Before the signature is verified, the bootloader takes from the header only what the hash
cannot be computed without, the size, and immediately checks it against the boundary of the area
slot. Otherwise a forgery with a huge size makes the bootloader read
past the end of Flash, and it crashes with a bus fault. The experiment checks this:
the bootloader rejects the size 0xFFFFFF00 before hashing.
The original image and five forgeries:
| What was done to the image | Bootloader verdict |
|---|---|
| nothing | start |
| one bit of the body changed | signature mismatch |
| one bit of the signature changed | signature mismatch |
| version in the header raised to 9 without re-signing | signature mismatch |
| verified with someone else's public key | signature mismatch |
| an old image of version 2, honestly signed with the same key | version below the allowed one |
The last case is a rollback: the attacker holds an old, genuine, correctly signed firmware with a known vulnerability. The signature lets it through: it really is yours. Without the version comparison the bootloader would start it, and the experiment showed that too. That is why the lowest allowed version is stored in the device and only grows: it is raised after a successful update.
And the order: the version is compared after the signature is verified; before that the number in the header cannot be trusted.
48а.3 Verification with another library
The signature made by mbedTLS was verified with the cryptography library (Python, based on OpenSSL), and the verification passed. Moreover, this library can sign according to the same RFC 6979, and its signature of the same image with the same key matched the mbedTLS signature byte for byte. The public key computed by both from the same private key matched as well.
This matters in practice: signing is usually done on the build server (OpenSSL, Python, vendor utilities), and verification in the device (mbedTLS). The format, two halves of 32 bytes each (r and s), must match; many utilities output the signature by default in DER format, and you have to split it into r and s.
48а.4 What it costs in the firmware
The same verification was built for Cortex-M4 (arm-none-eabi-gcc -Os, a trimmed mbedTLS
config: only big numbers, the P-256 curve, ECDSA and SHA-256):
| Program | Flash (code + constants) |
|---|---|
| without signature verification | 5780 bytes |
| with signature verification | 23028 bytes |
| difference | 17248 bytes |
About 17 KB is a noticeable but manageable part of a bootloader. mbedTLS allocates
memory for big numbers through calloc, so the bootloader needs a heap, or
its own memory allocation function.
Time. The emulator does not count cycles, but it counts instructions exactly. In this trimmed
config the verification takes 99.5 million instructions. Two lines from the regular mbedTLS build,
MBEDTLS_ECP_NIST_OPTIM (fast modular reduction for the NIST curves)
and MBEDTLS_HAVE_ASM (big-number multiplication in assembly), cut
it to 17.1 million instructions on Cortex-M4 and 18.4 million on Cortex-M3 at the cost of
half a kilobyte of Flash. An instruction costs one cycle or more, so this is a lower
bound: on the F407 at 168 MHz it is at least 0.1 s, on the F103 at 72 MHz
at least 0.26 s, and in the trimmed config 1.4 s. The real time is measured
on the board with the cycle counter DWT->CYCCNT (section 21.9). It is added
to every start-up and must fit into the bootloader's watchdog
interval.
For series with TrustZone there are ready-made solutions: for example, for the L5 the installed
packs contain the TF-M platform (STM32L5xx_TFM-PF) with image verification. For
some families without TrustZone there is the vendor's secure boot package
(X-CUBE-SBSFU) and open-source bootloaders with signature verification; you write your own, as here,
when their size or design does not fit.
48а.5 Where to keep the public key
The key itself is not a secret, but replacing it is a complete win for the attacker: having replaced the key with his own, he can sign anything. Therefore:
- the bootloader with the key sits in an area protected from writing (WRP, section 48.9);
- debugging is locked. The first RDP level does not let a debugger read the firmware, but it allows removing the protection at the cost of erasing the whole Flash and writing your own code, a bootloader together with its own key. To make the bootloader with the key impossible to replace, you need the second RDP level (irreversible, no more debugging, section 11.6) or a protected bootloader area in the families that have one; the F1 has no second level;
- the first stage that verifies the signature is never updated itself: this is the root of trust. If the bootloader must be updatable too, it is split in two: a small immutable first stage verifies the signature of the second, updatable one, and that one verifies the application. Each link checks the next, starting from the one that does not change: this is the chain of trust.
The private key is kept outside the repository, preferably in a hardware store on the build server; losing the key means you have nothing left to update the shipped devices with.
48а.6 Pitfalls
Only the body was signed. The version in the header is changed without re-signing, and a rollback gets through. Hash the header and the body together.
The version was checked before the signature. A number from unsigned data can be anything. The signature first, then the version.
No rollback protection. An old signed firmware with a hole starts: the experiment showed "start" without the version comparison.
The signature "does not match" although everything is right. The server outputs DER, while the bootloader expects r‖s of 32 bytes each, or the other way round.
The hash is computed over the wrong bytes. Alignment, padding to a page, different body boundaries in the signer and in the bootloader. Take the boundaries from the signed header.
A forgery with a huge size crashes the bootloader. The size from a not yet verified header was not checked against the area boundary, and the hash reads past the end of Flash.
The private key is in the repository. It will leak, and the signature means nothing.
The bootloader has no write protection. The key is replaced together with the bootloader.
The bootloader ran out of memory. mbedTLS allocates memory for numbers through
calloc: you need a heap.
48а.7 Summary
- CRC protects against corruption, a signature against replacement. The private key is only with the signer; the device holds the public one.
- ECDSA P-256 + SHA-256: a 64-byte signature, a public key of 65; the header is signed together with the body.
- Of the five attempts to cheat, the signature rejected four; the fifth, an honestly signed old image, is rejected only by the version comparison after the signature check.
- The mbedTLS signature was verified by another library and matched its signature byte for byte.
- On Cortex-M4 the verification costs about 17 KB of Flash and needs a heap; in time it is from 17 to 100 million instructions depending on the config, from a tenth of a second on every start-up.
- The bootloader with the key is under write protection and the second RDP level: it is the root of trust. The first level is removed by a debugger by erasing, and then it writes its own code.
Full sources
sboot.c: image signing and verification in the bootloader on mbedTLS, five attempts to cheat.
/* Раздел 48а: подпись прошивки ECDSA P-256 + SHA-256 на mbedTLS 3.6.4.
*
* Образ: заголовок {магия, версия, размер} + тело. Подписывается хеш заголовка и тела вместе.
* Подписант (компьютер) держит закрытый ключ; загрузчик (прибор) — только открытый и минимальную версию.
* Ключ и подпись детерминированы: закрытый ключ — SHA-256 от строки, подпись — RFC 6979, поэтому
* прогон каждый раз даёт одни и те же байты, и их можно сверить посторонней библиотекой. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "mbedtls/ecdsa.h"
#include "mbedtls/ecp.h"
#include "mbedtls/hmac_drbg.h"
#include "mbedtls/md.h"
#include "mbedtls/sha256.h"
#define BODY 4096
struct hdr { uint32_t magic, version, size; };
static uint8_t image[sizeof(struct hdr) + BODY];
static void hex(const char *name, const uint8_t *p, size_t n)
{
printf("%s=", name);
for (size_t i = 0; i < n; i++)
printf("%02x", p[i]);
printf("\n");
}
static void build(uint32_t version)
{
struct hdr h = {0x47535746u /* 'FWSG' */, version, BODY};
memcpy(image, &h, sizeof h);
for (int i = 0; i < BODY; i++)
image[sizeof h + i] = (uint8_t)(i * 7 + 3); /* «прошивка» — любой повторяемый узор */
}
/* ── подписант ── */
static mbedtls_ecp_group grp;
static mbedtls_mpi d; /* закрытый ключ */
static mbedtls_ecp_point Q; /* открытый ключ */
static mbedtls_hmac_drbg_context drbg; /* только для защиты умножения точки, на результат не влияет */
static void keygen(const char *seed, mbedtls_mpi *dk, mbedtls_ecp_point *Qk)
{
uint8_t h[32];
mbedtls_sha256((const uint8_t *)seed, strlen(seed), h, 0);
mbedtls_mpi_read_binary(dk, h, 32);
mbedtls_mpi_mod_mpi(dk, dk, &grp.N);
mbedtls_ecp_mul(&grp, Qk, dk, &grp.G, mbedtls_hmac_drbg_random, &drbg);
}
static void sign(uint8_t sig[64])
{
uint8_t h[32];
mbedtls_mpi r, s;
mbedtls_mpi_init(&r);
mbedtls_mpi_init(&s);
mbedtls_sha256(image, sizeof image, h, 0);
mbedtls_ecdsa_sign_det_ext(&grp, &r, &s, &d, h, 32, MBEDTLS_MD_SHA256, mbedtls_hmac_drbg_random, &drbg);
mbedtls_mpi_write_binary(&r, sig, 32);
mbedtls_mpi_write_binary(&s, sig + 32, 32);
mbedtls_mpi_free(&r);
mbedtls_mpi_free(&s);
}
/* ── загрузчик ── */
enum verdict { BOOT_OK, BAD_MAGIC, BAD_SIZE, BAD_SIGNATURE, TOO_OLD };
static const char *const VERDICT[] = {"запуск", "не образ", "размер больше области", "подпись не сошлась", "версия ниже разрешённой"};
/* slot — размер области, где лежит образ: размер из заголовка до проверки подписи — только в её пределах */
static enum verdict boot_check(const uint8_t *img, size_t slot, const uint8_t sig[64], const mbedtls_ecp_point *pub,
uint32_t min_version)
{
struct hdr h;
memcpy(&h, img, sizeof h);
if (h.magic != 0x47535746u)
return BAD_MAGIC;
if (h.size > slot - sizeof h) /* размер ещё не проверен подписью: сначала граница области */
return BAD_SIZE;
uint8_t hash[32];
mbedtls_sha256(img, sizeof h + h.size, hash, 0);
mbedtls_mpi r, s;
mbedtls_mpi_init(&r);
mbedtls_mpi_init(&s);
mbedtls_mpi_read_binary(&r, sig, 32);
mbedtls_mpi_read_binary(&s, sig + 32, 32);
int rc = mbedtls_ecdsa_verify(&grp, hash, 32, pub, &r, &s);
mbedtls_mpi_free(&r);
mbedtls_mpi_free(&s);
if (rc != 0)
return BAD_SIGNATURE;
if (h.version < min_version) /* проверяется ПОСЛЕ подписи: версии без подписи верить нельзя */
return TOO_OLD;
return BOOT_OK;
}
int main(void)
{
mbedtls_ecp_group_init(&grp);
mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1);
mbedtls_mpi_init(&d);
mbedtls_ecp_point_init(&Q);
mbedtls_hmac_drbg_init(&drbg);
mbedtls_hmac_drbg_seed_buf(&drbg, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), (const uint8_t *)"48a", 3);
keygen("handbook-48a signing key", &d, &Q);
uint8_t pub[65];
size_t plen;
mbedtls_ecp_point_write_binary(&grp, &Q, MBEDTLS_ECP_PF_UNCOMPRESSED, &plen, pub, sizeof pub);
hex("pubkey", pub, plen);
build(3);
uint8_t h[32], sig[64], sig_v2[64];
mbedtls_sha256(image, sizeof image, h, 0);
printf("image_bytes=%zu\n", sizeof image);
hex("sha256", h, 32);
sign(sig);
hex("signature", sig, 64);
uint8_t sig2[64];
sign(sig2);
printf("deterministic=%d\n", memcmp(sig, sig2, 64) == 0);
FILE *f = fopen("/tmp/image.bin", "wb");
if (f) { fwrite(image, 1, sizeof image, f); fclose(f); }
const uint32_t MINV = 3;
printf("case original: %s\n", VERDICT[boot_check(image, sizeof image, sig, &Q, MINV)]);
image[sizeof(struct hdr) + 1000] ^= 0x01;
printf("case one_bit_in_body: %s\n", VERDICT[boot_check(image, sizeof image, sig, &Q, MINV)]);
image[sizeof(struct hdr) + 1000] ^= 0x01;
sig[40] ^= 0x80;
printf("case one_bit_in_signature: %s\n", VERDICT[boot_check(image, sizeof image, sig, &Q, MINV)]);
sig[40] ^= 0x80;
image[4] = 9; /* подняли версию в заголовке, не переподписав */
printf("case version_patched: %s\n", VERDICT[boot_check(image, sizeof image, sig, &Q, MINV)]);
image[4] = 3;
{
mbedtls_mpi d2;
mbedtls_ecp_point Q2;
mbedtls_mpi_init(&d2);
mbedtls_ecp_point_init(&Q2);
keygen("someone else's key", &d2, &Q2);
printf("case other_public_key: %s\n", VERDICT[boot_check(image, sizeof image, sig, &Q2, MINV)]);
}
{
const uint32_t huge = 0xFFFFFF00u; /* подделали размер: загрузчик не должен читать за областью */
const uint32_t body = BODY;
memcpy(image + 8, &huge, sizeof huge);
printf("case huge_size: %s\n", VERDICT[boot_check(image, sizeof image, sig, &Q, MINV)]);
memcpy(image + 8, &body, sizeof body);
}
/* откат: старый образ версии 2 — подписан тем же ключом и честно */
build(2);
sign(sig_v2);
printf("case old_signed_v2: %s\n", VERDICT[boot_check(image, sizeof image, sig_v2, &Q, MINV)]);
printf("case old_signed_v2_without_version_check: %s\n", VERDICT[boot_check(image, sizeof image, sig_v2, &Q, 0)]);
printf("sizes pubkey=%zu signature=64 hash=32\n", plen);
return 0;
}
Next: section 48б "MCUboot on STM32: two slots, image swap and rollback" — a ready-made bootloader: slots, swap through scratch, a trailer with flags, and rollback.