Проверка цифровой подписи
Контроллеры 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 |
|
Входит в подписываемую строку |
140–171 |
32 |
|
Входит в подписываемую строку |
172–177 |
6 |
|
Входит в подписываемую строку |
256–271 |
16 |
|
Имя первого файла, |
293 |
1 |
|
Алгоритм подписи |
464–527 |
64 |
|
Подпись |
Алгоритм задан байтом signature_version записи device.id:
Значение |
Алгоритм |
Размер подписи |
Расположение |
|---|---|---|---|
0 |
Подписи нет |
— |
Поле |
1 |
ECDSA, кривая secp192r1 (NIST P-192) |
48 байт |
|
2 |
ECDSA, кривая secp256r1 (NIST P-256) |
64 байта |
|
Контроллеры серии 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, поэтому перед проверкой ее преобразуют.
Открытые ключи
Файл |
Кривая |
|
|---|---|---|
secp256r1 (NIST P-256) |
2 |
|
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
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjuIk0O0ejl3yepPyD3vpwCunqcHL
iNaVOz06zelIkqK9vxwlOXb0fig10k2BzIPNzXA6+uIBb3JGxai77FApVQ==
-----END PUBLIC KEY-----
-----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
Установите зависимости:
pip install jeefs cryptography
Скачайте в один каталог скрипт
verify_eeprom_signature.pyи открытые ключи.Запустите скрипт, указав файл дампа:
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), файл дампа или ключа не читается, либо ключ не соответствует алгоритму.
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).