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

usid (board header)

Included in the signed string

140–171

32

cpuid (board header)

Included in the signed string

172–177

6

mac (board header)

Included in the signed string

256–271

16

name (file header)

Name of the first file, device.id

293

1

signature_version (device.id)

Signature algorithm

464–527

64

signature (device.id)

Signature

The algorithm is specified by the signature_version byte of the device.id record:

Value

Algorithm

Signature size

Position of r and s

0

No signature

—

The signature field is filled with zeros

1

ECDSA, curve secp192r1 (NIST P-192)

48 bytes

r — bytes 464–487, s — 488–511, bytes 512–527 are zero

2

ECDSA, curve secp256r1 (NIST P-256)

64 bytes

r — bytes 464–495, s — 496–527

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 the cpuid field up to the first zero byte, unchanged. For JXD series controllers, this is the factory MAC address of the ESP32 microcontroller in the form XX:XX:XX:XX:XX:XX, digits in uppercase.

  • mac — 6 bytes of the mac field, written as 12 hexadecimal digits in uppercase without separators.

  • usid — contents of the usid field 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

signature_version

secp256r1.pem

secp256r1 (NIST P-256)

2

secp192r1.pem

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
secp256r1.pem
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjuIk0O0ejl3yepPyD3vpwCunqcHL
iNaVOz06zelIkqK9vxwlOXb0fig10k2BzIPNzXA6+uIBb3JGxai77FApVQ==
-----END PUBLIC KEY-----
secp192r1.pem
-----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

  1. Install the dependencies:

    pip install jeefs cryptography
    
  2. Download the script verify_eeprom_signature.py and the public keys into one directory.

  3. 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 or device.id record missing, corrupted or unsupported, filesystem version unknown, no signature in the record (signature_version equals 0), dump or key file unreadable, or the key does not match the algorithm.

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())

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, mac and usid were 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.id record or its signature_version equals 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).