Digital signature verification
JetHome controllers of the JXD series receive a device digital signature at manufacturing. The signature is stored in the device.id record in the EEPROM of the processor module and links three identifiers from the board header: CPU ID of the microcontroller, MAC address, and the internal device identifier USID. A valid signature means that these values were written by JetHome and have not changed since.
A EEPROM dump and the JetHome public key are required for verification. Access to JetHome servers is not required.
Where the signature is located
Warning
The signature covers only the cpuid, mac, and usid values. The remaining board header data and device.id records, including the device model and serial number, are protected only by CRC32 checksums. They detect data corruption but not intentional modification.
The signature and signed values are located in the EEPROM of the processor module (see EEPROM data format). On the JXD-R6-E1ETH controller this is the chip at address 0x54 on the internal I2C bus (GPIO4 — SCL, GPIO5 — SDA).
The device.id record is always the first file of the file system; therefore, with a board header size of 256 bytes, its location is fixed: the file header occupies bytes 256–283, the record — bytes 284–539. The offsets in the table are counted from the beginning of the EEPROM.
Offset |
Size |
Field |
Purpose |
|---|---|---|---|
108–139 |
32 |
|
Included in the signed string |
140–171 |
32 |
|
Included in the signed string |
172–177 |
6 |
|
Included in the signed string |
256–271 |
16 |
|
Name of the first file, |
293 |
1 |
|
Signature algorithm |
464–527 |
64 |
|
Signature |
The algorithm is specified by the signature_version byte of the device.id record:
Value |
Algorithm |
Signature size |
Position of |
|---|---|---|---|
0 |
No signature |
— |
The |
1 |
ECDSA, curve secp192r1 (NIST P-192) |
48 bytes |
|
2 |
ECDSA, curve secp256r1 (NIST P-256) |
64 bytes |
|
JXD series controllers are signed with algorithm 2 (secp256r1).
Data to be signed
A string of three board header values separated by a colon is signed:
<cpuid>:<mac>:<usid>
cpuid— the contents of thecpuidfield up to the first zero byte, unchanged. For JXD series controllers, this is the factory MAC address of the ESP32 microcontroller in the formXX:XX:XX:XX:XX:XX, digits in uppercase.mac— 6 bytes of themacfield, written as 12 hexadecimal digits in uppercase without separators.usid— contents of theusidfield up to the first zero byte.
Example string:
D0:EF:76:00:00:01:F0578D000010:jxde1_0100260100000000000001ab
The string is encoded in UTF-8, a SHA-256 hash is computed from it, and the hash is signed with ECDSA. The signature is stored as two numbers r and s of fixed length in big-endian order, written consecutively, without DER encoding. Most cryptographic libraries require the signature to be passed in DER, so it is converted before verification.
Public keys
File |
Curve |
|
|---|---|---|
secp256r1 (NIST P-256) |
2 |
|
secp192r1 (NIST P-192) |
1 |
SHA-256 fingerprints of the keys in DER encoding:
secp256r1.pem 7fdc7a86e3082ee4e4b000d824983bef13e540677519631247a15007aa4bd46a
secp192r1.pem 7ec06565a2e9981e82aef1d873f1e3728a5d0a51cbdc59195c3b0a9d74a1290a
Verify the fingerprint of the downloaded key:
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-----
Obtaining an EEPROM dump
Warning
Do not write data to the beginning of the processor module’s EEPROM. The test example from section ESP-IDF writes data to the first memory block and destroys the board header and the device.id record along with the signature. Restoring the signature without JetHome’s private key is impossible.
For verification, the first 540 bytes of the processor module’s EEPROM are needed: the board header, the first file header, and the device.id record. Read them over the I2C bus using your firmware, for example based on ESP-IDF, and save them to a file. A binary file or a string of hexadecimal digits is suitable; spaces and line breaks in it are allowed.
Verification with a Python script
Install the dependencies:
pip install jeefs cryptography
Download the script
verify_eeprom_signature.pyand the public keys into one directory.Run the script, specifying the dump file:
python3 verify_eeprom_signature.py eeprom.bin
The script selects the key by the value of signature_version in the device.id record. A different key file is specified by the --key parameter.
Example output:
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)
Script exit code:
0— signature is valid;1— signature is invalid;2— verification not performed: board header ordevice.idrecord missing, corrupted or unsupported, filesystem version unknown, no signature in the record (signature_versionequals 0), dump or key file unreadable, or the key does not match the algorithm.
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())
Verification using OpenSSL
Without Python, the signature is verified with the openssl and xxd utilities. The commands below are for algorithm 2 (secp256r1) and a board header of 256 bytes. They expect the dump eeprom.bin and the key secp256r1.pem in the current directory:
# 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
The first two commands should output device.id and 02. With a valid signature, the last command outputs Verified OK, with an invalid one — Verification failure. For algorithm 1 (secp192r1, byte 293 equals 01) r and s take 24 bytes each: use -s 464 -l 24 -p -c 24 and -s 488 -l 24 -p -c 24, as well as the key secp192r1.pem.
Verification result
Signature is valid — the values of
cpuid,macandusidwere written by JetHome and have not been modified.Signature is invalid — the values have been modified or corrupted. Contact JetHome technical support.
No signature — there is no
device.idrecord or itssignature_versionequals 0: the device was not signed during manufacturing.
The signature confirms the origin of the data in EEPROM, but not that the chip is on its own processor module. To exclude the transfer of EEPROM from another module, compare the cpuid value with the factory MAC address of the ESP32 microcontroller, ignoring the letter case. The factory MAC address is output by the command esptool read-mac (in esptool 4.x — esptool.py read_mac).