Crypto & Framing

The secure envelope, the key derivation, and the CRC16 that holds it together. Recovered from com.sineng.uniplugin_module.utils.a (m57 / m58) and com.sineng.uniplugin_module.service.WifiTcpService.


Key derivation

Both keys derive from the dongle serial number alone — there is no negotiation, no challenge, and no other secret:

crc        = crc16(S)                       # see CRC16 below
aes_key    = MD5(S || crc.to_bytes(2, "big"))
hmac_key   = MD5(reverse(S) || crc(reverse(S)).to_bytes(2, "big"))

In Python, via sitsolar_dongle.crypto.derive_keys:

from sitsolar_dongle import derive_keys

keys = derive_keys("<DONGLE_SERIAL>")
keys.aes     # 16 bytes
keys.hmac    # 16 bytes
keys.crc     # CRC16 of the serial, for cross-checking

Envelope

The payload portion of every frame is:

IV(16) | ciphertext (AES-128-CBC, PKCS#7) | HMAC-SHA256(32)
Part Size Notes
IV 16 Random per frame (os.urandom).
ciphertext multiple of 16 AES-128-CBC over the PKCS#7-padded payload.
HMAC-SHA256 32 Computed over IV \|\| ciphertext.

Verification order

open_envelope() verifies the HMAC before decrypting, exactly as the vendor's utils.a.m57 does:

  1. Recompute HMAC-SHA256(IV || ciphertext) with hmac_key.
  2. Compare in constant time against the trailing 32 bytes.
  3. Only on a match, AES-decrypt and strip PKCS#7 padding.

Any failure raises EnvelopeError rather than returning garbage, so a wrong serial surfaces as the clean message:

EnvelopeError: HMAC mismatch (wrong serial number?)
from sitsolar_dongle import seal, open_envelope, derive_keys, EnvelopeError

keys = derive_keys("<DONGLE_SERIAL>")
envelope = seal(b"\x42\x02\x00\x08general\x00", keys)

try:
    payload = open_envelope(envelope, keys)
except EnvelopeError as exc:
    print("authentication failed:", exc)

Pass verify=False to open_envelope to skip the MAC check — useful only when debugging captured traffic with a known-good key.

Padding

PKCS#7, block size 16. A padding value outside 1..16, or bytes that do not match it, raises EnvelopeError("bad PKCS#7 padding (key or IV mismatch)"). That message is a strong hint that the AES key is wrong while the HMAC key is right — see the CRC16 warning below.


CRC16

Used in exactly two places:

  • appended to the inner Modbus RTU frame, big-endian (high byte first);
  • mixed into the AES / HMAC key derivation together with the serial.
from sitsolar_dongle import crc16, crc16_bytes

crc16(b"<DONGLE_SERIAL>")          # int
crc16_bytes(b"\x01\x04\x75\x94")    # 2 bytes, big-endian, ready to append

Why the tables are delicate

Table A holds only the values 0, 1, 64, 65, 128, 129, 192, and 193, and its four 64-entry quarters are not all identical — entries 224..239 in particular break the pattern of the preceding quarter.

The consequence of getting this wrong is subtle and worth spelling out:

A single wrong row silently corrupts the AES key, while the HMAC key — derived from the reversed serial — keeps working.

That produces the very confusing symptom where every frame authenticates but nothing decrypts: the MAC check passes, then AES yields garbage, and PKCS#7 padding validation fails.

Guard rails

sitsolar_dongle.crc.selftest() verifies the tables against known vectors. The module refuses to start unless it passes, and the test suite (Development) pins the tables against vectors taken from the APK and from a live dongle.


Frame construction

DongleClient seals a payload in two steps, because the outer header must survive in the clear while the protocol id is patched afterwards:

  1. wrap_payload() builds the plaintext frame: tid | protocol | length | unit | payload.
  2. The 7-byte header is split off; the remaining payload is sealed.
  3. The header is re-prefixed to the envelope, then the protocol id is written into bytes 2-3 (0x1000 for requests, 0x1001 for heartbeat replies) and the length field is recomputed as len(envelope) + 1.

The official app reaches the same bytes by leaving the protocol id at 0x0000 and patching byte 2 to 0x10 only after encrypting. Writing both bytes up front is equivalent on the wire, and it is what lets requests and heartbeat replies share one function.

# from sitsolar_dongle.session.seal_frame
plain = wrap_payload(payload, tid, protocol)
header, body = plain[:7], plain[7:]
envelope = seal(body, keys)
out = bytearray(header + envelope)
out[2] = (protocol >> 8) & 0xFF
out[3] = protocol & 0xFF
out[4] = (len(envelope) + 1) >> 8
out[5] = (len(envelope) + 1) & 0xFF

Heartbeat replies do the same, then set the protocol low byte to 0x01 (result 0x1001), per WifiTcpService.m50.


Next