Radix 16 (live-verified), universal reply splitter, golden capture vectors, host_probe6

This commit is contained in:
kfj001 committed 2026-10-16 02:50:23 +00:00
1 parent 0cd6b34a2e
commit 62e0fd649b
10 files changed
+418 -82

No files matched your search

+72 -23
View File
@@ -5,42 +5,42 @@ Reconstructed from the blutter disassembly; see
Request wire form of an envelope::
s = compact JSON, e.g. {"cmdType":25,"equipNo":"SN",...}
s = compact JSON, e.g. {"cmdType":1101,"equipNo":"SN",...}
S1 = parts[0] + "," + parts[1] + "," # quartet header, trailing comma
S2 = ",".join(parts[2:]) # the rest of the JSON
a = UTF-8(S1) # sent PLAINTEXT first
n = int.tryParse(key, radix: 32) or 0
n = int.tryParse(key) or 0 # default radix 16
wire = a || bytes((S2[i] + i + len(a) + n) & 0xFF)
i.e. the two leading JSON parts go on the wire **unchanged first**, and the
tail is transformed byte-wise with a position-dependent additive stream
(index ``i`` is relative to the tail; ``i + len(a)`` is the absolute JSON
index). The receiver that knows the serial can compute ``len(a)`` up front
(``header_len_for``) and split the wire deterministically.
index). Both directions use this same framing (verified live: a 1154-byte
device reply decrypts with the identical split — see ``decrypt_wire``).
Responses use a different framing (``decrypt_data``): the wire holds the
plaintext JSON prefix ``s`` (which itself contains the serial's ``X``),
a literal ``0x58`` separator, then the encoded tail::
.. note:: An earlier revision claimed radix 32 from a ``0x20`` literal
next to the ``tryParse`` call sites. Two independent live frames (an
app discovery request and a device info reply) decrypt cleanly only
with radix 16 (``int("80", 16) == 128``, ``int("1c", 16) == 28``) and
then verify byte-exact (``len``/uppercase CRC-32). Ground truth wins:
the ``0x20`` belongs to a neighbouring call.
wire = UTF-8(s) || 0x58 || encode(tail)
``decrypt_data`` locates the *second* ``0x58`` byte ``j``, takes
``s = UTF-8(wire[:j])``, probes ``s + "}"`` as JSON to recover ``equipNo``
(and hence the key), decodes the tail with the same stream in reverse, and
returns ``s + "," + tail_plain``. Anything unexpected yields ``""`` (the
app's ``decryptData`` returns ``""`` on marker/parse failure and the socket
layer keeps accumulating bytes).
Confirmed from the disassembly:
Confirmed from the disassembly *and live traffic*:
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
with **no** zero padding (Dart ``_toPow2String(16)``).
* The key is parsed with ``int.tryParse(key, radix: 32)`` — the literal
``0x20`` (32) is materialised right before the call in both the encrypt
and decrypt paths.
with **no** zero padding (Dart ``_toPow2String(16)``). E.g. ``"1c"`` for
the gateway SN, ``"80"`` for the empty/placeholder SN.
* The key is parsed with ``int.tryParse(key)`` — default radix **16**,
verified against two live frames (``tests/fixtures/*.bin``).
* ``encryptObject(object, key)`` takes the object in the first slot and the
key in the second; its result is ``a ++ transform(b)`` (plaintext header
first — ``addAll(transformed)`` onto the header copy).
* Replies use the same ``S1 || transform(S2)`` framing (the device info
reply decrypts identically). The reply ``cmdType`` observed live is the
request id + 1 (``1101`` → ``1102``); replies are therefore parsed with
:func:`decrypt_wire`, which locates the header without knowing the ids
up front. ``decrypt_data`` (``0x58``-marker model) is kept for the
Bluetooth path, which is unverified live.
"""
from __future__ import annotations
@@ -48,8 +48,10 @@ from __future__ import annotations
import json
from typing import Optional, Tuple
#: Key radix used by both ``encryptObject`` and ``decryptData`` (literal 0x20).
DEFAULT_KEY_RADIX = 32
#: Key radix used by the envelope cipher (default ``tryParse`` radix).
#: Verified live: ``int("80", 16) == 128`` and ``int("1c", 16) == 28``
#: decode two independent captured frames byte-exact.
DEFAULT_KEY_RADIX = 16
#: Response separator byte (``0x58``, ASCII ``X``).
MARKER = 0x58
@@ -143,6 +145,53 @@ def decrypt_request(
decrypt_object = decrypt_request
def split_wire(wire: bytes) -> Tuple[bytes, bytes]:
"""Split any envelope wire into ``(a, b)`` at the 2nd comma.
The header ``S1 = parts[0] + "," + parts[1] + ","`` is always ASCII
with no commas inside, so the first two commas on the wire are its
structural boundaries — no ids or serials needed up front. Works for
requests and replies alike.
"""
raw = bytes(wire)
first = raw.find(b",")
if first < 0:
raise ValueError("no header comma in wire buffer")
second = raw.find(b",", first + 1)
if second < 0:
raise ValueError("truncated header in wire buffer")
return raw[: second + 1], raw[second + 1:]
def decrypt_wire(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
"""Decode one complete envelope wire (request or reply) to JSON.
Splits via :func:`split_wire`, recovers ``equipNo`` from the
``a + "}"`` probe (exactly how the receiver learns which key to use),
and reverses the additive stream. Raises ``ValueError`` on any
failure (use :func:`decrypt_data`-style ``""`` handling when
accumulating).
"""
a, b = split_wire(wire)
try:
# ``a`` ends with the header's trailing comma — drop it so the
# probe is valid JSON (mirrors the ``s + "}"`` equipNo probe).
probe = json.loads(a[:-1].decode("utf-8") + "}")
except (UnicodeDecodeError, ValueError) as exc:
raise ValueError(f"header probe is not JSON: {exc}") from exc
if not isinstance(probe, dict):
raise ValueError("header probe is not an object")
key = generate_secret_key(str(probe.get("equipNo", "")))
key_int = _parse_key(key, radix)
plain_b = bytes(
(byte - i - len(a) - key_int) & 0xFF for i, byte in enumerate(b)
)
try:
return (a + plain_b).decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"tail is not UTF-8: {exc}") from exc
def decrypt_data(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
"""``decryptData`` — decode one accumulated *response* buffer.