Проверка цифровой подписи

Контроллеры JetHome серии JXD получают при производстве цифровую подпись устройства. Подпись хранится в записи device.id в EEPROM процессорного модуля и связывает три идентификатора из заголовка платы: CPU ID микроконтроллера, MAC-адрес и внутренний идентификатор устройства USID. Верная подпись означает, что эти значения записаны JetHome и с тех пор не изменялись.

Для проверки нужны дамп EEPROM и открытый ключ JetHome. Обращаться к серверам JetHome не требуется.

Где находится подпись

Warning

Подпись покрывает только значения cpuid, mac и usid. Остальные данные заголовка платы и записи device.id, в том числе модель и серийный номер устройства, защищены только контрольными суммами CRC32. Они выявляют повреждение данных, но не их намеренное изменение.

Подпись и подписываемые значения находятся в EEPROM процессорного модуля (см. Формат данных EEPROM). У контроллера JXD-R6-E1ETH это микросхема по адресу 0x54 на внутренней шине I2C (GPIO4 — SCL, GPIO5 — SDA).

Запись device.id всегда первый файл файловой системы, поэтому при заголовке платы размером 256 байт ее расположение фиксировано: заголовок файла занимает байты 256–283, запись — байты 284–539. Смещения в таблице отсчитываются от начала EEPROM.

Смещение

Размер

Поле

Назначение

108–139

32

usid (заголовок платы)

Входит в подписываемую строку

140–171

32

cpuid (заголовок платы)

Входит в подписываемую строку

172–177

6

mac (заголовок платы)

Входит в подписываемую строку

256–271

16

name (заголовок файла)

Имя первого файла, device.id

293

1

signature_version (device.id)

Алгоритм подписи

464–527

64

signature (device.id)

Подпись

Алгоритм задан байтом signature_version записи device.id:

Значение

Алгоритм

Размер подписи

Расположение r и s

0

Подписи нет

—

Поле signature заполнено нулями

1

ECDSA, кривая secp192r1 (NIST P-192)

48 байт

r — байты 464–487, s — 488–511, байты 512–527 нулевые

2

ECDSA, кривая secp256r1 (NIST P-256)

64 байта

r — байты 464–495, s — 496–527

Контроллеры серии JXD подписываются алгоритмом 2 (secp256r1).

Подписываемые данные

Подписывается строка из трех значений заголовка платы, разделенных двоеточием:

<cpuid>:<mac>:<usid>
  • cpuid — содержимое поля cpuid до первого нулевого байта, без изменений. У контроллеров серии JXD это заводской MAC-адрес микроконтроллера ESP32 в виде XX:XX:XX:XX:XX:XX, цифры в верхнем регистре.

  • mac — 6 байт поля mac, записанные 12 шестнадцатеричными цифрами в верхнем регистре без разделителей.

  • usid — содержимое поля usid до первого нулевого байта.

Пример строки:

D0:EF:76:00:00:01:F0578D000010:jxde1_0100260100000000000001ab

Строка кодируется в UTF-8, от нее вычисляется хеш SHA-256, хеш подписывается ECDSA. Подпись хранится как два числа r и s фиксированной длины в порядке big-endian, записанные подряд, без кодирования DER. Большинству криптографических библиотек подпись нужно передавать в DER, поэтому перед проверкой ее преобразуют.

Открытые ключи

Файл

Кривая

signature_version

secp256r1.pem

secp256r1 (NIST P-256)

2

secp192r1.pem

secp192r1 (NIST P-192)

1

Отпечатки SHA-256 ключей в кодировке DER:

secp256r1.pem  7fdc7a86e3082ee4e4b000d824983bef13e540677519631247a15007aa4bd46a
secp192r1.pem  7ec06565a2e9981e82aef1d873f1e3728a5d0a51cbdc59195c3b0a9d74a1290a

Сверьте отпечаток скачанного ключа:

openssl pkey -pubin -in secp256r1.pem -outform DER | openssl dgst -sha256
secp256r1.pem
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjuIk0O0ejl3yepPyD3vpwCunqcHL
iNaVOz06zelIkqK9vxwlOXb0fig10k2BzIPNzXA6+uIBb3JGxai77FApVQ==
-----END PUBLIC KEY-----
secp192r1.pem
-----BEGIN PUBLIC KEY-----
MEkwEwYHKoZIzj0CAQYIKoZIzj0DAQEDMgAE+kODfSJugKYgpMA60yrsHEZATdMy
hGs7WdjYi8OCGsDB0r0Xpt9kTlUzITumWTmv
-----END PUBLIC KEY-----

Получение дампа EEPROM

Warning

Не записывайте данные в начало EEPROM процессорного модуля. Тестовый пример из раздела ESP-IDF записывает данные в первый блок памяти и уничтожает заголовок платы и запись device.id вместе с подписью. Восстановить подпись без закрытого ключа JetHome невозможно.

Для проверки нужны первые 540 байт EEPROM процессорного модуля: заголовок платы, заголовок первого файла и запись device.id. Прочитайте их по шине I2C средствами своей прошивки, например на основе ESP-IDF, и сохраните в файл. Подойдет двоичный файл или строка шестнадцатеричных цифр, пробелы и переводы строк в ней допускаются.

Проверка скриптом на Python

  1. Установите зависимости:

    pip install jeefs cryptography
    
  2. Скачайте в один каталог скрипт verify_eeprom_signature.py и открытые ключи.

  3. Запустите скрипт, указав файл дампа:

    python3 verify_eeprom_signature.py eeprom.bin
    

Скрипт выбирает ключ по значению signature_version записи device.id. Другой файл ключа задается параметром --key.

Пример вывода:

header        v4
board         JXD-CPU-E1ETH 1.3
board_serial  B-0001
cpuid         D0:EF:76:00:00:01
mac           F0:57:8D:00:00:10
usid          jxde1_0100260100000000000001ab
device        JXD-R6-E1ETH 2.0, serial 900000001
timestamp     2026-01-01 00:00:00 UTC
payload       D0:EF:76:00:00:01:F0578D000010:jxde1_0100260100000000000001ab
signature     valid (secp256r1, key secp256r1.pem)

Код завершения скрипта:

  • 0 — подпись верна;

  • 1 — подпись неверна;

  • 2 — проверка не выполнена: заголовок платы или запись device.id отсутствует, повреждена или не поддерживается, версия файловой системы незнакома, в записи нет подписи (signature_version равен 0), файл дампа или ключа не читается, либо ключ не соответствует алгоритму.

verify_eeprom_signature.py
  1#!/usr/bin/env python3
  2"""Verify the JetHome device signature stored in an EEPROM dump.
  3
  4Usage:
  5    python3 verify_eeprom_signature.py DUMP [--key PUBLIC_KEY.pem]
  6
  7DUMP holds the first 540 bytes of the CPU module EEPROM - the board
  8header, the header of the first file and the device.id record - as raw
  9binary or as a hex string. Without --key the JetHome public key matching
 10the record's signature_version is taken from the script's directory.
 11
 12Requirements: pip install jeefs cryptography
 13
 14Exit status: 0 - signature valid, 1 - signature invalid, 2 - the check
 15could not run: the board header or the device.id record is missing,
 16damaged or unsupported, the filesystem version is unknown, the record
 17carries no signature, or the dump or the key cannot be read.
 18"""
 19
 20import argparse
 21import binascii
 22import struct
 23import sys
 24from datetime import datetime, timezone
 25from pathlib import Path
 26
 27from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
 28from cryptography.hazmat.primitives import hashes
 29from cryptography.hazmat.primitives.asymmetric import ec
 30from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
 31from cryptography.hazmat.primitives.serialization import load_pem_public_key
 32from jeefs import (
 33    DEVICE_ID_FILENAME,
 34    EEPROM_MAGIC,
 35    DeviceIdentityV1,
 36    EEPROMHeaderV3,
 37    EEPROMHeaderV4,
 38    SignatureAlgorithm,
 39    detect_version,
 40)
 41
 42HEADER_CLASSES = {3: EEPROMHeaderV3, 4: EEPROMHeaderV4}
 43HEADER_SIZE = 256
 44FS_VERSION = 1  # the current filesystem version
 45# File header: name, dataSize, crc32, nextFileAddress, headerCrc32.
 46FILE_HEADER = struct.Struct("<16sHIHI")
 47RECORD_SIZE = 256
 48# device.id is always the first file, right after the board header.
 49RECORD_OFFSET = HEADER_SIZE + FILE_HEADER.size
 50DUMP_SIZE = RECORD_OFFSET + RECORD_SIZE
 51
 52# signature_version -> (curve, default public key file)
 53ALGORITHMS = {
 54    SignatureAlgorithm.SECP192R1: (ec.SECP192R1, "secp192r1.pem"),
 55    SignatureAlgorithm.SECP256R1: (ec.SECP256R1, "secp256r1.pem"),
 56}
 57
 58
 59def show(name: str, value: str) -> None:
 60    print(f"{name:<14}{value}")
 61
 62
 63def crc32(data: bytes) -> int:
 64    return binascii.crc32(data) & 0xFFFFFFFF
 65
 66
 67def load_dump(path: Path) -> bytes:
 68    data = path.read_bytes()
 69    try:
 70        return bytes.fromhex(data.decode("ascii"))
 71    except ValueError:
 72        return data  # not a hex string: a raw binary dump
 73
 74
 75def device_record(data: bytes) -> bytes:
 76    """Return the device.id record from the first file slot.
 77
 78    Raises ValueError with the reason when there is no valid record.
 79    """
 80    if len(data) < DUMP_SIZE:
 81        raise ValueError(f"the dump is too short: read at least {DUMP_SIZE} bytes")
 82    raw = data[HEADER_SIZE:RECORD_OFFSET]
 83    if raw[0] in (0x00, 0xFF):
 84        raise ValueError("there are no files: the device is not signed")
 85    name, size, data_crc, _next, header_crc = FILE_HEADER.unpack(raw)
 86    if crc32(raw[:-4]) != header_crc:
 87        raise ValueError("file header CRC32 mismatch")
 88    if name.split(b"\0")[0] != DEVICE_ID_FILENAME.encode() or size != RECORD_SIZE:
 89        raise ValueError(f"the first file is not {DEVICE_ID_FILENAME}: the device is not signed")
 90    record = data[RECORD_OFFSET:DUMP_SIZE]
 91    if crc32(record) != data_crc:
 92        raise ValueError("file data CRC32 mismatch")
 93    return record
 94
 95
 96def signed_payload(header: EEPROMHeaderV3) -> bytes:
 97    """Rebuild the signed string: "<cpuid>:<mac>:<usid>".
 98
 99    The values come from the board header. The mac field is written as
100    12 upper-case hex digits without separators; cpuid and usid are
101    taken verbatim.
102    """
103    mac = header.mac.replace(":", "")
104    return f"{header.cpuid}:{mac}:{header.usid}".encode("utf-8")
105
106
107def main() -> int:
108    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
109    parser.add_argument("dump", type=Path, help="EEPROM dump, binary or hex")
110    parser.add_argument("--key", type=Path, help="public key in PEM format")
111    args = parser.parse_args()
112
113    try:
114        data = load_dump(args.dump)
115    except OSError as err:
116        print(f"{args.dump}: {err.strerror}")
117        return 2
118
119    version = detect_version(data)
120    if version is None:
121        if data[:8] != EEPROM_MAGIC:
122            print("No JetHome header: the magic does not match")
123        elif len(data) < 12:
124            # detect_version() needs the magic, the version byte and the
125            # three bytes after it.
126            print(f"Dump too short: {len(data)} bytes")
127        else:
128            print(f"Unsupported header version: {data[8]}")
129        return 2
130    if version not in HEADER_CLASSES:
131        print(f"Header v{version} is not supported by this script")
132        return 2
133    if not EEPROMHeaderV3.verify_crc_static(data):
134        print("Board header CRC32 mismatch: the dump is damaged or incomplete")
135        return 2
136    try:
137        header = HEADER_CLASSES[version].from_bytes(data)
138    except ValueError as err:
139        print(f"Unsupported board header: {err}")
140        return 2
141    show("header", f"v{version}")
142    show("board", f"{header.boardname} {header.boardversion}")
143    show(header.SERIAL_LABEL, header.serial)
144    show("cpuid", header.cpuid)
145    show("mac", header.mac)
146    show("usid", header.usid)
147
148    if header.fs_version > FS_VERSION:
149        print(f"Unsupported filesystem version: {header.fs_version}")
150        return 2
151    try:
152        raw = device_record(data)
153        record = DeviceIdentityV1.from_bytes(raw)
154    except ValueError as err:
155        print(f"{DEVICE_ID_FILENAME}: {err}")
156        return 2
157    if not record.verify_crc(raw):
158        print(f"{DEVICE_ID_FILENAME}: record CRC32 mismatch")
159        return 2
160    show("device", f"{record.device_model} {record.hw_revision}, serial {record.device_serial}")
161    if record.timestamp:
162        try:
163            written = datetime.fromtimestamp(record.timestamp, timezone.utc)
164        except (OverflowError, OSError, ValueError):
165            # The signature does not cover the timestamp: any int64 fits.
166            show("timestamp", f"{record.timestamp} (out of range)")
167        else:
168            show("timestamp", f"{written:%Y-%m-%d %H:%M:%S} UTC")
169
170    algorithm = record.signature_algorithm
171    if algorithm == SignatureAlgorithm.NONE:
172        show("signature", "none (signature_version = 0)")
173        return 2
174    curve, key_file = ALGORITHMS[algorithm]
175    key_path = args.key or Path(__file__).with_name(key_file)
176    try:
177        public_key = load_pem_public_key(key_path.read_bytes())
178    except (OSError, ValueError, UnsupportedAlgorithm) as err:
179        print(f"{key_path}: cannot load the public key: {err}")
180        return 2
181    if not isinstance(public_key, ec.EllipticCurvePublicKey) or public_key.curve.name != curve.name:
182        print(f"{key_path}: not an EC {curve.name} public key")
183        return 2
184
185    payload = signed_payload(header)
186    show("payload", payload.decode())
187
188    # The record stores the raw r||s pair; cryptography expects DER.
189    half = len(record.signature) // 2
190    r = int.from_bytes(record.signature[:half], "big")
191    s = int.from_bytes(record.signature[half:], "big")
192    try:
193        public_key.verify(encode_dss_signature(r, s), payload, ec.ECDSA(hashes.SHA256()))
194    except InvalidSignature:
195        show("signature", f"INVALID ({curve.name})")
196        return 1
197    show("signature", f"valid ({curve.name}, key {key_path.name})")
198    return 0
199
200
201if __name__ == "__main__":
202    sys.exit(main())

Проверка с помощью OpenSSL

Без Python подпись проверяется утилитами openssl и xxd. Команды ниже — для алгоритма 2 (secp256r1) и заголовка платы размером 256 байт. Они ожидают в текущем каталоге дамп eeprom.bin и ключ secp256r1.pem:

# The first file must be device.id, signed with algorithm 2
dd if=eeprom.bin bs=1 skip=256 count=16 2>/dev/null | tr '\0' '\n' | head -1
xxd -s 293 -l 1 -p eeprom.bin

# Signed string: cpuid, mac, usid from the board header
CPUID=$(dd if=eeprom.bin bs=1 skip=140 count=32 2>/dev/null | tr '\0' '\n' | head -1)
MAC=$(xxd -s 172 -l 6 -p -u eeprom.bin)
USID=$(dd if=eeprom.bin bs=1 skip=108 count=32 2>/dev/null | tr '\0' '\n' | head -1)
printf '%s:%s:%s' "$CPUID" "$MAC" "$USID" > payload.txt

# Signature from device.id: raw r||s -> DER
cat > sig.cnf <<EOF
asn1 = SEQUENCE:sig
[sig]
r = INTEGER:0x$(xxd -s 464 -l 32 -p -c 32 eeprom.bin)
s = INTEGER:0x$(xxd -s 496 -l 32 -p -c 32 eeprom.bin)
EOF
openssl asn1parse -genconf sig.cnf -out sig.der -noout

openssl dgst -sha256 -verify secp256r1.pem -signature sig.der payload.txt

Первые две команды должны вывести device.id и 02. При верной подписи последняя команда выводит Verified OK, при неверной — Verification failure. Для алгоритма 1 (secp192r1, байт 293 равен 01) r и s занимают по 24 байта: используйте -s 464 -l 24 -p -c 24 и -s 488 -l 24 -p -c 24, а также ключ secp192r1.pem.

Результат проверки

  • Подпись верна — значения cpuid, mac и usid записаны JetHome и не изменялись.

  • Подпись неверна — значения изменены или повреждены. Обратитесь в техническую поддержку JetHome.

  • Подписи нет — записи device.id нет или ее signature_version равен 0: устройство не подписывалось при производстве.

Подпись подтверждает происхождение данных в EEPROM, но не то, что микросхема стоит на своем процессорном модуле. Чтобы исключить перенос EEPROM с другого модуля, сравните значение cpuid с заводским MAC-адресом микроконтроллера ESP32, без учета регистра букв. Заводской MAC-адрес выводит команда esptool read-mac (в esptool 4.x — esptool.py read_mac).