Embedded HandbookПриложения
Все разделы › VI. Практикум
STM32F407, STM32F103 · уровень продвинутый · 13 мин чтения
Открыть в интерактивном справочнике

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, честно подписанный тем же ключом версия ниже разрешённой

Последний случай — откат: у злоумышленника на руках старая, настоящая, правильно подписанная прошивка с известной уязвимостью. Подпись её пропустит — она действительно ваша. Без сравнения версии загрузчик запустил бы её, что опыт тоже показал. Поэтому наименьшая разрешённая версия хранится в приборе и только растёт — после успешного обновления её поднимают.

И последовательность: версию сравнивают после проверки подписи — до этого числу в заголовке верить нельзя.

Подписанный образ: SHA-256 накрывает заголовок и тело вместе, подпись 64 байта отдельно, в загрузчике открытый ключ 65 байт и наименьшая разрешённая версия; из шести опытов запуск только у нетронутого образа, четыре подделки отбивает подпись, старый честно подписанный образ версии 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 Где хранить открытый ключ

Сам ключ секретом не является, но его подмена — это полная победа злоумышленника: подменив ключ на свой, он подпишет что угодно. Поэтому:

Закрытый ключ хранят вне репозитория, лучше — в аппаратном хранилище сервера сборки; потеря ключа означает, что обновлять выпущенные приборы больше нечем.


48а.6 Грабли

Подписали только тело. Версию в заголовке меняют без переподписи — откат проходит. Хеш — от заголовка и тела вместе.

Версию проверили до подписи. Число из неподписанных данных — любое. Сначала подпись, потом версия.

Защиты от отката нет. Старая подписанная прошивка с дырой запускается — опыт показал «запуск» без сравнения версии.

Подпись «не сходится», хотя всё верно. Сервер выдаёт DER, а загрузчик ждёт r‖s по 32 байта — или наоборот.

Хеш считают не от тех байт. Выравнивание, дополнение до страницы, разные границы тела у подписанта и у загрузчика. Границы — из подписанного заголовка.

Подделка с огромным размером роняет загрузчик. Размер из ещё не проверенного заголовка не сверили с границей области — хеш читает за концом Flash.

Закрытый ключ в репозитории. Он утечёт — и подпись ничего не значит.

Загрузчик без защиты от записи. Ключ подменят вместе с загрузчиком.

Не хватило памяти в загрузчике. mbedTLS берёт память под числа через calloc: нужна куча.


48а.7 Итоги

Полные исходники

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, хвост с флагами и откат.

Открыть в интерактивном справочнике