48а. Подпись прошивки: загрузчик проверяет, кто её сделал
Загрузчик из раздела 48 проверяет образ по CRC-32 — и это защищает только от случайной порчи. Кто угодно, изменив прошивку, пересчитает CRC за секунду, и загрузчик честно её запустит. Чтобы прибор запускал только вашу прошивку, её подписывают: на компьютере — закрытым ключом, в загрузчике проверяют открытым. Подделать подпись без закрытого ключа нельзя, а закрытого ключа в приборе нет вовсе.
Раздел — опыт на библиотеке mbedTLS 3.6.4 из пакета: подпись ECDSA по кривой
P-256 и хеш SHA-256, пять попыток обмануть загрузчик, перекрёстная проверка
другой библиотекой и размер проверки подписи в прошивке Cortex-M4.
Программы — _verify/sboot48a/. Сверяет _verify/sboot48a_check.py;
время проверки — _verify/fixw2a0/sig48 и _verify/fixw2a0_check.py.
Как это называется в документации
Оригинал Перевод digital signature цифровая подпись private key, public key закрытый и открытый ключ ECDSA, curve P-256 (secp256r1) подпись на эллиптической кривой P-256 message digest, SHA-256 хеш (дайджест), SHA-256 deterministic ECDSA (RFC 6979) детерминированная подпись: одинаковые данные — одинаковая подпись anti-rollback, security counter защита от отката на старую версию root of trust корень доверия: то, что проверяет первым и само не меняется chain of trust цепочка доверия
48а.1 Схема
| Где | Что лежит | Что делает |
|---|---|---|
| компьютер разработчика или сервер сборки | закрытый ключ (32 байта) | считает SHA-256 образа, подписывает хеш |
| образ прошивки | заголовок + тело + подпись (64 байта) | переезжает в прибор любым путём |
| загрузчик в приборе | открытый ключ (65 байт) и наименьшая разрешённая версия | считает хеш, проверяет подпись, сверяет версию |
Главное: подписывается и заголовок тоже. Иначе версию, размер и адрес запуска можно подменить, не трогая подписанное тело.
Открытый ключ не секрет — его можно показать всем. Секрет — закрытый ключ, и он не должен попадать ни в прибор, ни в репозиторий.
48а.2 Опыт: подпись и пять попыток обмана
Образ: заголовок {магия FWSG, версия 3, размер} и 4096 байт тела — всего
4108 байт. Ключ получен детерминированно (SHA-256 от строки — так делают
только в опыте, чтобы прогон повторялся; ключ изделия генерируют из настоящего
случайного источника и хранят вне репозитория), подпись —
по RFC 6979, где случайное число выводится из ключа и хеша, поэтому прогон
каждый раз даёт те же байты:
sha256=698c2f41db06f429f74bbcd60f5c2b38c00535e9b1062e0f719be752087f12ce
signature=c38bdb835bfe9f6cd4356d36ad82e5506097c047ea1600ae51cfbdb664e3d8bd
bc345987fa0c4e9884f892ce87f659ffbc70c2ab8e1c1112070e18c90e8e3865
Проверка в загрузчике — порядок важен:
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_ecdsa_verify и только потом сравнение версии.
До проверки подписи из заголовка берут только то, без чего хеш
не посчитать, — размер — и сразу сверяют его с границей области
slot. Иначе подделка с огромным размером заставит загрузчик читать
за концом Flash, и он упадёт с отказом шины. Опыт это проверяет:
размер 0xFFFFFF00 загрузчик отбивает до хеширования.
Исходный образ и пять подделок:
| Что сделали с образом | Вердикт загрузчика |
|---|---|
| ничего | запуск |
| изменили один бит тела | подпись не сошлась |
| изменили один бит подписи | подпись не сошлась |
| подняли версию в заголовке до 9, не переподписав | подпись не сошлась |
| проверили чужим открытым ключом | подпись не сошлась |
| старый образ версии 2, честно подписанный тем же ключом | версия ниже разрешённой |
Последний случай — откат: у злоумышленника на руках старая, настоящая, правильно подписанная прошивка с известной уязвимостью. Подпись её пропустит — она действительно ваша. Без сравнения версии загрузчик запустил бы её, что опыт тоже показал. Поэтому наименьшая разрешённая версия хранится в приборе и только растёт — после успешного обновления её поднимают.
И последовательность: версию сравнивают после проверки подписи — до этого числу в заголовке верить нельзя.
48а.3 Проверка другой библиотекой
Подпись, сделанную mbedTLS, проверили библиотекой cryptography (Python, основана на OpenSSL) — проверка прошла. Больше того, эта библиотека умеет подписывать по тому же RFC 6979, и её подпись того же образа тем же ключом совпала с подписью mbedTLS байт в байт. Открытый ключ, посчитанный обеими из одного закрытого, тоже совпал.
Это важно на практике: подписывают обычно на сервере сборки (OpenSSL, Python, утилиты производителя), проверяют в приборе (mbedTLS). Формат — две половины по 32 байта (r и s) — должен совпадать; во многих утилитах подпись по умолчанию в формате DER, и её приходится разбирать на r и s.
48а.4 Сколько это стоит в прошивке
Та же проверка собрана для Cortex-M4 (arm-none-eabi-gcc -Os, урезанный конфиг
mbedTLS: только большие числа, кривая P-256, ECDSA и SHA-256):
| Программа | Flash (код + константы) |
|---|---|
| без проверки подписи | 5 780 байт |
| с проверкой подписи | 23 028 байт |
| разница | 17 248 байт |
Около 17 КБ — заметная, но подъёмная часть загрузчика. mbedTLS выделяет
память под большие числа через calloc, так что загрузчику нужна куча — или
своя функция выделения памяти.
Время. Такты эмулятор не считает, а команды — точно. В этом урезанном
конфиге проверка — 99,5 млн команд. Две строки из обычной сборки mbedTLS —
MBEDTLS_ECP_NIST_OPTIM (быстрое приведение по модулю для кривых NIST)
и MBEDTLS_HAVE_ASM (умножение больших чисел на ассемблере) — сокращают
её до 17,1 млн команд на Cortex-M4 и 18,4 млн на Cortex-M3 ценой
полукилобайта Flash. Команда стоит от такта и больше, поэтому это нижняя
граница: на F407 при 168 МГц — не меньше 0,1 с, на F103 при 72 МГц —
не меньше 0,26 с, а в урезанном конфиге — 1,4 с. Настоящее время мерят
на плате счётчиком тактов DWT->CYCCNT (раздел 21.9). Оно прибавляется
к каждому старту и должно укладываться в интервал сторожевого таймера
загрузчика.
Для серий с TrustZone есть готовые решения: например, для L5 в установленных
пакетах лежит платформа TF-M (STM32L5xx_TFM-PF) с проверкой образов. Для
части семейств без TrustZone есть пакет безопасной загрузки производителя
(X-CUBE-SBSFU) и открытые загрузчики с проверкой подписи; свой, как здесь,
пишут, когда их размер или устройство не подходят.
48а.5 Где хранить открытый ключ
Сам ключ секретом не является, но его подмена — это полная победа злоумышленника: подменив ключ на свой, он подпишет что угодно. Поэтому:
- загрузчик с ключом лежит в области, защищённой от записи (WRP, раздел 48.9);
- отладка закрыта. Первый уровень RDP не даёт отладчику прочитать прошивку, но позволяет снять защиту ценой стирания всей Flash и записать своё — загрузчик вместе со своим ключом. Чтобы загрузчик с ключом нельзя было заменить, нужен второй уровень RDP (необратим, отладки больше нет, раздел 11.6) или защищённая область загрузчика у семейств, где она есть; у F1 второго уровня нет;
- первый этап, который проверяет подпись, сам не обновляется — это и есть корень доверия. Если загрузчик тоже нужно обновлять, его делят на два: маленький неизменяемый первый этап проверяет подпись второго, обновляемого, а тот — приложения. Каждое звено проверяет следующее, начиная с того, которое не меняется, — это и есть цепочка доверия.
Закрытый ключ хранят вне репозитория, лучше — в аппаратном хранилище сервера сборки; потеря ключа означает, что обновлять выпущенные приборы больше нечем.
48а.6 Грабли
Подписали только тело. Версию в заголовке меняют без переподписи — откат проходит. Хеш — от заголовка и тела вместе.
Версию проверили до подписи. Число из неподписанных данных — любое. Сначала подпись, потом версия.
Защиты от отката нет. Старая подписанная прошивка с дырой запускается — опыт показал «запуск» без сравнения версии.
Подпись «не сходится», хотя всё верно. Сервер выдаёт DER, а загрузчик ждёт r‖s по 32 байта — или наоборот.
Хеш считают не от тех байт. Выравнивание, дополнение до страницы, разные границы тела у подписанта и у загрузчика. Границы — из подписанного заголовка.
Подделка с огромным размером роняет загрузчик. Размер из ещё не проверенного заголовка не сверили с границей области — хеш читает за концом Flash.
Закрытый ключ в репозитории. Он утечёт — и подпись ничего не значит.
Загрузчик без защиты от записи. Ключ подменят вместе с загрузчиком.
Не хватило памяти в загрузчике. mbedTLS берёт память под числа через
calloc: нужна куча.
48а.7 Итоги
- CRC защищает от порчи, подпись — от подмены. Закрытый ключ — только у подписанта, в приборе — открытый.
- ECDSA P-256 + SHA-256: подпись 64 байта, открытый ключ 65; подписывают заголовок вместе с телом.
- Из пяти попыток обмана подпись отбила четыре; пятую — честно подписанный старый образ — отбивает только сравнение версии после проверки подписи.
- Подпись mbedTLS проверена другой библиотекой и совпала с её подписью байт в байт.
- На Cortex-M4 проверка стоит около 17 КБ Flash и требует кучи; по времени — от 17 до 100 млн команд в зависимости от конфига, от десятой доли секунды на каждый старт.
- Загрузчик с ключом — под защитой от записи и вторым уровнем RDP: это корень доверия. Первый уровень отладчик снимает стиранием и пишет своё.
Полные исходники
sboot.c — подпись образа и проверка в загрузчике на mbedTLS, пять попыток обмана.
/* Раздел 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;
}
Дальше: раздел 48б «MCUboot на STM32: два слота, обмен образов и откат» — готовый загрузчик: слоты, обмен через scratch, хвост с флагами и откат.