Radix 16 (live-verified), universal reply splitter, golden capture vectors, host_probe6
This commit is contained in:
1 parent
0cd6b34a2e
commit
62e0fd649b
10 files changed
+418
-82
No files matched your search
@@ -26,10 +26,12 @@ from .crypto import (
|
||||
decrypt_data,
|
||||
decrypt_object,
|
||||
decrypt_request,
|
||||
decrypt_wire,
|
||||
encrypt_object,
|
||||
generate_secret_key,
|
||||
header_len_for,
|
||||
split_envelope,
|
||||
split_wire,
|
||||
)
|
||||
from .envelope import (
|
||||
KEY_ORDER,
|
||||
@@ -81,6 +83,8 @@ __all__ = [
|
||||
"decrypt_object",
|
||||
"decrypt_request",
|
||||
"decrypt_data",
|
||||
"decrypt_wire",
|
||||
"split_wire",
|
||||
"header_len_for",
|
||||
"KEY_ORDER",
|
||||
"SNNO_MIN",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -224,11 +224,12 @@ class SocketFrameDecoder:
|
||||
|
||||
def feed(self, data: bytes) -> List[str]:
|
||||
"""Append *data*; return a list of complete decoded JSON strings."""
|
||||
from .crypto import decrypt_data # local import: cheap, no cycles
|
||||
from .crypto import decrypt_wire # local import: cheap, no cycles
|
||||
|
||||
self._buf.extend(data)
|
||||
decoded = decrypt_data(bytes(self._buf))
|
||||
if not decoded:
|
||||
try:
|
||||
decoded = decrypt_wire(bytes(self._buf))
|
||||
except ValueError:
|
||||
return []
|
||||
try:
|
||||
json.loads(decoded)
|
||||
|
||||
Reference in new issue
Block a user