@ecopoesis/homebridge-dmx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/CLAUDE.md +176 -0
  2. package/README.md +146 -0
  3. package/config.schema.json +143 -0
  4. package/dist/cli/stick.d.ts +3 -0
  5. package/dist/cli/stick.d.ts.map +1 -0
  6. package/dist/cli/stick.js +176 -0
  7. package/dist/cli/stick.js.map +1 -0
  8. package/dist/color/cct.d.ts +3 -0
  9. package/dist/color/cct.d.ts.map +1 -0
  10. package/dist/color/cct.js +33 -0
  11. package/dist/color/cct.js.map +1 -0
  12. package/dist/color/dimmer.d.ts +3 -0
  13. package/dist/color/dimmer.d.ts.map +1 -0
  14. package/dist/color/dimmer.js +16 -0
  15. package/dist/color/dimmer.js.map +1 -0
  16. package/dist/color/hsvcct.d.ts +3 -0
  17. package/dist/color/hsvcct.d.ts.map +1 -0
  18. package/dist/color/hsvcct.js +52 -0
  19. package/dist/color/hsvcct.js.map +1 -0
  20. package/dist/color/index.d.ts +7 -0
  21. package/dist/color/index.d.ts.map +1 -0
  22. package/dist/color/index.js +28 -0
  23. package/dist/color/index.js.map +1 -0
  24. package/dist/color/parsers.d.ts +20 -0
  25. package/dist/color/parsers.d.ts.map +1 -0
  26. package/dist/color/parsers.js +232 -0
  27. package/dist/color/parsers.js.map +1 -0
  28. package/dist/color/rgb.d.ts +3 -0
  29. package/dist/color/rgb.d.ts.map +1 -0
  30. package/dist/color/rgb.js +32 -0
  31. package/dist/color/rgb.js.map +1 -0
  32. package/dist/color/rgbaw.d.ts +3 -0
  33. package/dist/color/rgbaw.d.ts.map +1 -0
  34. package/dist/color/rgbaw.js +48 -0
  35. package/dist/color/rgbaw.js.map +1 -0
  36. package/dist/color/rgbw.d.ts +3 -0
  37. package/dist/color/rgbw.d.ts.map +1 -0
  38. package/dist/color/rgbw.js +37 -0
  39. package/dist/color/rgbw.js.map +1 -0
  40. package/dist/color/rgbww.d.ts +3 -0
  41. package/dist/color/rgbww.d.ts.map +1 -0
  42. package/dist/color/rgbww.js +54 -0
  43. package/dist/color/rgbww.js.map +1 -0
  44. package/dist/color/types.d.ts +64 -0
  45. package/dist/color/types.d.ts.map +1 -0
  46. package/dist/color/types.js +91 -0
  47. package/dist/color/types.js.map +1 -0
  48. package/dist/config.d.ts +60 -0
  49. package/dist/config.d.ts.map +1 -0
  50. package/dist/config.js +191 -0
  51. package/dist/config.js.map +1 -0
  52. package/dist/controller.d.ts +32 -0
  53. package/dist/controller.d.ts.map +1 -0
  54. package/dist/controller.js +128 -0
  55. package/dist/controller.js.map +1 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +7 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/platform.d.ts +17 -0
  61. package/dist/platform.d.ts.map +1 -0
  62. package/dist/platform.js +96 -0
  63. package/dist/platform.js.map +1 -0
  64. package/dist/platformAccessory.d.ts +18 -0
  65. package/dist/platformAccessory.d.ts.map +1 -0
  66. package/dist/platformAccessory.js +81 -0
  67. package/dist/platformAccessory.js.map +1 -0
  68. package/dist/settings.d.ts +14 -0
  69. package/dist/settings.d.ts.map +1 -0
  70. package/dist/settings.js +15 -0
  71. package/dist/settings.js.map +1 -0
  72. package/dist/stick/frame.d.ts +16 -0
  73. package/dist/stick/frame.d.ts.map +1 -0
  74. package/dist/stick/frame.js +51 -0
  75. package/dist/stick/frame.js.map +1 -0
  76. package/dist/stick/kdf.d.ts +23 -0
  77. package/dist/stick/kdf.d.ts.map +1 -0
  78. package/dist/stick/kdf.js +90 -0
  79. package/dist/stick/kdf.js.map +1 -0
  80. package/dist/stick/protocol.d.ts +28 -0
  81. package/dist/stick/protocol.d.ts.map +1 -0
  82. package/dist/stick/protocol.js +78 -0
  83. package/dist/stick/protocol.js.map +1 -0
  84. package/dist/stick/session.d.ts +50 -0
  85. package/dist/stick/session.d.ts.map +1 -0
  86. package/dist/stick/session.js +301 -0
  87. package/dist/stick/session.js.map +1 -0
  88. package/examples/dmx.yaml +65 -0
  89. package/package.json +51 -0
  90. package/src/cli/stick.ts +198 -0
  91. package/src/color/cct.ts +40 -0
  92. package/src/color/dimmer.ts +24 -0
  93. package/src/color/hsvcct.ts +59 -0
  94. package/src/color/index.ts +34 -0
  95. package/src/color/parsers.ts +232 -0
  96. package/src/color/rgb.ts +33 -0
  97. package/src/color/rgbaw.ts +46 -0
  98. package/src/color/rgbw.ts +36 -0
  99. package/src/color/rgbww.ts +56 -0
  100. package/src/color/types.ts +150 -0
  101. package/src/config.ts +248 -0
  102. package/src/controller.ts +136 -0
  103. package/src/index.ts +9 -0
  104. package/src/platform.ts +118 -0
  105. package/src/platformAccessory.ts +102 -0
  106. package/src/settings.ts +18 -0
  107. package/src/stick/frame.ts +70 -0
  108. package/src/stick/kdf.ts +99 -0
  109. package/src/stick/protocol.ts +90 -0
  110. package/src/stick/session.ts +348 -0
  111. package/stick-de3.json +6 -0
  112. package/tools/aes-hook.c +1404 -0
  113. package/tools/analyze-pcap.mjs +143 -0
  114. package/tools/capture-aes-key.lldb +25 -0
  115. package/tools/capture-and-verify.sh +46 -0
  116. package/tools/capture-handshake.sh +59 -0
  117. package/tools/capture-send.sh +39 -0
  118. package/tools/crack-kdf.mjs +121 -0
  119. package/tools/crack-key.mjs +170 -0
  120. package/tools/decode-frame.mjs +110 -0
  121. package/tools/decrypt-011c.mjs +123 -0
  122. package/tools/decrypt-dmx.mjs +130 -0
  123. package/tools/decrypt-ssl2.ts +154 -0
  124. package/tools/derive-dmx-key.mjs +189 -0
  125. package/tools/diff-011c.mjs +127 -0
  126. package/tools/dump-pcap-summary.mjs +54 -0
  127. package/tools/dump-tcp-stream.mjs +123 -0
  128. package/tools/extract-011c.mjs +117 -0
  129. package/tools/extract-frame.mjs +78 -0
  130. package/tools/find-011c-key.mjs +140 -0
  131. package/tools/find-iv.mjs +153 -0
  132. package/tools/ghidra/README.md +136 -0
  133. package/tools/ghidra/__pycache__/dump-011c-callers.cpython-313.pyc +0 -0
  134. package/tools/ghidra/__pycache__/dump-011c-handler.cpython-313.pyc +0 -0
  135. package/tools/ghidra/__pycache__/dump-aes-candidates.cpython-313.pyc +0 -0
  136. package/tools/ghidra/__pycache__/dump-anchors.cpython-313.pyc +0 -0
  137. package/tools/ghidra/__pycache__/dump-auth2.cpython-313.pyc +0 -0
  138. package/tools/ghidra/__pycache__/dump-authenticate.cpython-313.pyc +0 -0
  139. package/tools/ghidra/__pycache__/dump-block-cipher.cpython-313.pyc +0 -0
  140. package/tools/ghidra/__pycache__/dump-cipher.cpython-313.pyc +0 -0
  141. package/tools/ghidra/__pycache__/dump-ctor-chain.cpython-313.pyc +0 -0
  142. package/tools/ghidra/__pycache__/dump-curve.cpython-313.pyc +0 -0
  143. package/tools/ghidra/__pycache__/dump-encompassing.cpython-313.pyc +0 -0
  144. package/tools/ghidra/__pycache__/dump-handshake.cpython-313.pyc +0 -0
  145. package/tools/ghidra/__pycache__/dump-kdf-chain.cpython-313.pyc +0 -0
  146. package/tools/ghidra/__pycache__/dump-kdf-context.cpython-313.pyc +0 -0
  147. package/tools/ghidra/__pycache__/dump-profile.cpython-313.pyc +0 -0
  148. package/tools/ghidra/__pycache__/dump-real-encrypt.cpython-313.pyc +0 -0
  149. package/tools/ghidra/__pycache__/dump-secret.cpython-313.pyc +0 -0
  150. package/tools/ghidra/__pycache__/dump-setsoftware.cpython-313.pyc +0 -0
  151. package/tools/ghidra/__pycache__/dump-stick3-vtable.cpython-313.pyc +0 -0
  152. package/tools/ghidra/__pycache__/dump-swxref.cpython-313.pyc +0 -0
  153. package/tools/ghidra/__pycache__/dump-vt-key-getter.cpython-313.pyc +0 -0
  154. package/tools/ghidra/__pycache__/find-cipher.cpython-313.pyc +0 -0
  155. package/tools/ghidra/__pycache__/find-ctor.cpython-313.pyc +0 -0
  156. package/tools/ghidra/__pycache__/find-md5.cpython-313.pyc +0 -0
  157. package/tools/ghidra/__pycache__/find-secret-users.cpython-313.pyc +0 -0
  158. package/tools/ghidra/__pycache__/find-stick3-crypto.cpython-313.pyc +0 -0
  159. package/tools/ghidra/__pycache__/find-stick3-device.cpython-313.pyc +0 -0
  160. package/tools/ghidra/__pycache__/find-stick3a-ctor.cpython-313.pyc +0 -0
  161. package/tools/ghidra/__pycache__/find-stick3anet-ctor.cpython-313.pyc +0 -0
  162. package/tools/ghidra/__pycache__/find-vtables.cpython-313.pyc +0 -0
  163. package/tools/ghidra/__pycache__/string-walk.cpython-313.pyc +0 -0
  164. package/tools/ghidra/dump-011c-callers.py +140 -0
  165. package/tools/ghidra/dump-011c-handler.py +158 -0
  166. package/tools/ghidra/dump-aes-candidates.py +99 -0
  167. package/tools/ghidra/dump-anchors.py +192 -0
  168. package/tools/ghidra/dump-auth2.py +50 -0
  169. package/tools/ghidra/dump-authenticate.py +71 -0
  170. package/tools/ghidra/dump-block-cipher.py +88 -0
  171. package/tools/ghidra/dump-cipher.py +106 -0
  172. package/tools/ghidra/dump-ctor-chain.py +107 -0
  173. package/tools/ghidra/dump-curve.py +112 -0
  174. package/tools/ghidra/dump-encompassing.py +146 -0
  175. package/tools/ghidra/dump-handshake.py +76 -0
  176. package/tools/ghidra/dump-kdf-chain.py +105 -0
  177. package/tools/ghidra/dump-kdf-context.py +72 -0
  178. package/tools/ghidra/dump-profile.py +59 -0
  179. package/tools/ghidra/dump-real-encrypt.py +90 -0
  180. package/tools/ghidra/dump-secret.py +84 -0
  181. package/tools/ghidra/dump-setsoftware.py +63 -0
  182. package/tools/ghidra/dump-stick3-vtable.py +182 -0
  183. package/tools/ghidra/dump-swxref.py +27 -0
  184. package/tools/ghidra/dump-vt-key-getter.py +170 -0
  185. package/tools/ghidra/find-cipher.py +210 -0
  186. package/tools/ghidra/find-ctor.py +109 -0
  187. package/tools/ghidra/find-md5.py +97 -0
  188. package/tools/ghidra/find-secret-users.py +98 -0
  189. package/tools/ghidra/find-stick3-crypto.py +190 -0
  190. package/tools/ghidra/find-stick3-device.py +111 -0
  191. package/tools/ghidra/find-stick3a-ctor.py +87 -0
  192. package/tools/ghidra/find-stick3anet-ctor.py +76 -0
  193. package/tools/ghidra/find-vtables.py +165 -0
  194. package/tools/ghidra/out-011c-callers-run.log +51 -0
  195. package/tools/ghidra/out-011c-run.log +50 -0
  196. package/tools/ghidra/out-vt-key-run.log +55 -0
  197. package/tools/ghidra/string-walk.py +180 -0
  198. package/tools/hmac-key.sh +51 -0
  199. package/tools/hwm-entitlements.plist +18 -0
  200. package/tools/inspect-plaintext.mjs +87 -0
  201. package/tools/lldb-capture-aes.py +137 -0
  202. package/tools/lldb_hmac_key.py +93 -0
  203. package/tools/lldb_pathA_scan.py +341 -0
  204. package/tools/lldb_pathC_keytrace.py +300 -0
  205. package/tools/lldb_pathD_kdf_watch.py +281 -0
  206. package/tools/pathA-launch-hwm.sh +86 -0
  207. package/tools/pathA-scan.sh +52 -0
  208. package/tools/pathC-keytrace.sh +49 -0
  209. package/tools/pathC-run.sh +71 -0
  210. package/tools/pathD-kdf-watch.sh +51 -0
  211. package/tools/quick-trigger.mjs +86 -0
  212. package/tools/run-hooked-hwmanager.sh +51 -0
  213. package/tools/scan-aes-schedule.mjs +135 -0
  214. package/tools/send_dmx.mjs +480 -0
  215. package/tools/stick-status.mjs +57 -0
  216. package/tools/test-aes-detector.mjs +100 -0
  217. package/tools/test-tcp-write.ts +274 -0
  218. package/tools/test-udp-dmx.ts +324 -0
  219. package/tools/test-x25519.mjs +87 -0
  220. package/tools/try-decrypt.ts +144 -0
  221. package/tools/try-hardcoded-key.mjs +86 -0
  222. package/tools/try-hardcoded-key.ts +103 -0
  223. package/tools/try-key.mjs +103 -0
  224. package/tools/verify-011c-key.mjs +121 -0
  225. package/tools/verify-kdf.mjs +142 -0
  226. package/tsconfig.json +19 -0
@@ -0,0 +1,189 @@
1
+ // Stick-DE3 DMX cipher — per-session AES-256 key derivation (KDF).
2
+ //
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // RECOVERED 2026-05-21 by pure static RE (Ghidra) of the 2024-03-21 ESA2
5
+ // HardwareManager. This is the LAST blocker for a standalone plugin: it lets
6
+ // the plugin derive its own per-session key without the Hardware Manager.
7
+ //
8
+ // Call chain (ESA2 file addrs):
9
+ // on_listWidgetDevice_itemSelectionChanged
10
+ // → FUN_100178030 → FUN_100107650 (the KDF)
11
+ // ├ vt[0x28] FUN_1001c0a50 opcode 0x10 — query Stick crypto state
12
+ // ├ FUN_1004032b0 — generate ephemeral P-256 keypair
13
+ // ├ vt[0x20] FUN_1001c0650 opcode 0x0F — exchange public keys
14
+ // ├ vt[0x38] FUN_100180e90 — return hardcoded static point (33B)
15
+ // ├ FUN_100402ab0 — SEC1 decompress the static point
16
+ // ├ FUN_1003ff3c0 ×2 — P-256 scalar multiply
17
+ // └ FUN_1003f5740 / FUN_1003f4530 — install 32B key at cipher+0x48/+0x68
18
+ //
19
+ // THE KDF (FUN_100107650), in full:
20
+ // 1. d = random scalar mod n (ephemeral private key)
21
+ // 2. our = d · G (our ephemeral public key)
22
+ // 3. send `our`, receive Stick pubkey `Q` (opcode 0x0F handshake)
23
+ // 4. S = decompress(STATIC_POINT_33B) (hardcoded, baked into the app)
24
+ // 5. AES-256 key = X(d · S) XOR X(d · Q)
25
+ //
26
+ // It is a TWO-DH construction: one DH against a fixed/pre-shared key `S`
27
+ // (authenticates the app) and one against the Stick's per-session key `Q`
28
+ // (gives forward secrecy). The Stick computes the identical key as
29
+ // X(s·our) XOR X(e·our), where s,e are its static/ephemeral privates —
30
+ // the two agree by ECDH symmetry.
31
+ //
32
+ // CURVE: NIST P-256 / secp256r1 / prime256v1. Confirmed three ways from the
33
+ // binary: prime p, order n, and base point G all byte-match P-256, and the
34
+ // curve `b` constant 5ac635d8…27d2604b is added during point decompression.
35
+ //
36
+ // ENDIANNESS: the Stick stores/wires every 256-bit coordinate as
37
+ // little-endian limbs (byte 0 = least-significant). Node's `crypto` works in
38
+ // big-endian (SEC1), so we byte-reverse at every boundary. The installed AES
39
+ // key is the little-endian form of X(d·S) XOR X(d·Q).
40
+ //
41
+ // ✓ VERIFIED end-to-end (2026-05-21): this derivation reproduces the exact
42
+ // AES-256 key installed in a real captured session — checked against an
43
+ // lldb-dumped ephemeral `d` + the Stick pubkey from the handshake pcap.
44
+ // See tools/verify-kdf.mjs.
45
+ // ════════════════════════════════════════════════════════════════════════
46
+ //
47
+ // Usage: node tools/derive-dmx-key.mjs (runs the self-test)
48
+
49
+ import crypto from 'node:crypto';
50
+
51
+ // ── P-256 domain parameters (standard; matched against the binary) ──────────
52
+ export const P256 = {
53
+ p: 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn,
54
+ n: 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n,
55
+ a: 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffcn, // -3 mod p
56
+ b: 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn,
57
+ Gx: 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n,
58
+ Gy: 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n,
59
+ };
60
+
61
+ // ── The hardcoded static public point S, recovered from the binary ──────────
62
+ // A 33-byte SEC1-compressed P-256 point stored VERBATIM at file offset
63
+ // 0x7b0490 (vmaddr 0x1007b0490). prefix 0x03 => odd Y. vt[0x38] hands this
64
+ // raw buffer straight to the SEC1 decompressor. (An earlier RE pass XOR-masked
65
+ // these bytes with 0x51,0x52,… — that produced a different value used
66
+ // elsewhere in the app; it is NOT the KDF static point. The raw bytes below
67
+ // are the ones that reproduce a real session's key — see verify-kdf.mjs.)
68
+ export const STATIC_POINT_COMPRESSED = Buffer.from(
69
+ '0328081290396d063e5114526ed978b926558f6ba1c96ad2facf76fffb3ffea0fe', 'hex');
70
+ export const STATIC_POINT_X = STATIC_POINT_COMPRESSED.subarray(1, 33).toString('hex');
71
+
72
+ // ── helpers ─────────────────────────────────────────────────────────────────
73
+ const rev = (b) => Buffer.from(b).reverse();
74
+ const pad32 = (b) => (b.length >= 32 ? b.subarray(b.length - 32) : Buffer.concat([Buffer.alloc(32 - b.length), b]));
75
+
76
+ /** SEC1-decompress a 33-byte compressed point to a 65-byte uncompressed point. */
77
+ export function decompressPoint(compressed33) {
78
+ const { p, a, b } = P256;
79
+ const prefix = compressed33[0];
80
+ const x = BigInt('0x' + compressed33.subarray(1, 33).toString('hex'));
81
+ const rhs = (((x * x % p) * x % p) + a * x % p + b) % p;
82
+ let y = modpow(rhs, (p + 1n) / 4n, p); // p ≡ 3 (mod 4)
83
+ if ((y * y) % p !== rhs) throw new Error('static point X is not on P-256');
84
+ if ((y & 1n) !== BigInt(prefix & 1)) y = p - y; // pick parity from prefix bit0
85
+ const hex = (v) => v.toString(16).padStart(64, '0');
86
+ return Buffer.from('04' + hex(x) + hex(y), 'hex');
87
+ }
88
+
89
+ function modpow(base, exp, mod) {
90
+ let r = 1n; base %= mod;
91
+ while (exp > 0n) { if (exp & 1n) r = r * base % mod; base = base * base % mod; exp >>= 1n; }
92
+ return r;
93
+ }
94
+
95
+ // ── wire encoding for the opcode-0x0F public-key exchange ────────────────────
96
+ // On the wire each point is 64 bytes: X (little-endian) ‖ Y (little-endian).
97
+
98
+ /** uncompressed SEC1 point (65B, 0x04‖Xbe‖Ybe) → 64-byte little-endian wire form */
99
+ export function pointToWire(uncompressed65) {
100
+ const xbe = uncompressed65.subarray(1, 33);
101
+ const ybe = uncompressed65.subarray(33, 65);
102
+ return Buffer.concat([rev(xbe), rev(ybe)]);
103
+ }
104
+
105
+ /** 64-byte little-endian wire point → uncompressed SEC1 point (65B) for node crypto */
106
+ export function wireToPoint(wire64) {
107
+ const xbe = rev(wire64.subarray(0, 32));
108
+ const ybe = rev(wire64.subarray(32, 64));
109
+ return Buffer.concat([Buffer.from([0x04]), xbe, ybe]);
110
+ }
111
+
112
+ // ── the KDF ─────────────────────────────────────────────────────────────────
113
+
114
+ /** Create the ephemeral P-256 keypair (step 1–2 of the KDF). */
115
+ export function makeEphemeral() {
116
+ const ecdh = crypto.createECDH('prime256v1');
117
+ ecdh.generateKeys();
118
+ return ecdh;
119
+ }
120
+
121
+ /**
122
+ * Derive the per-session AES-256 key.
123
+ * key = LE( X(d·S) XOR X(d·Q) )
124
+ * @param ecdh our ephemeral ECDH object (the private scalar d)
125
+ * @param stickPub65 the Stick's public key Q, uncompressed (65B, 0x04‖X‖Y)
126
+ * @returns 32-byte Buffer — the AES-256 key as the Stick installs it
127
+ */
128
+ export function deriveDmxKey(ecdh, stickPub65) {
129
+ const S = decompressPoint(STATIC_POINT_COMPRESSED);
130
+ const x1 = pad32(ecdh.computeSecret(S)); // X(d·S), big-endian
131
+ const x2 = pad32(ecdh.computeSecret(stickPub65)); // X(d·Q), big-endian
132
+ const xorBE = Buffer.alloc(32);
133
+ for (let i = 0; i < 32; i++) xorBE[i] = x1[i] ^ x2[i];
134
+ return rev(xorBE); // Stick stores the key in little-endian limb order
135
+ }
136
+
137
+ // ── self-test ────────────────────────────────────────────────────────────────
138
+ function selfTest() {
139
+ let ok = true;
140
+ const check = (name, cond) => { console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}`); ok &&= cond; };
141
+
142
+ console.log('Stick-DE3 DMX key-derivation — self-test\n');
143
+
144
+ // 1. static point is valid on P-256
145
+ const S = decompressPoint(STATIC_POINT_COMPRESSED);
146
+ check('static point S decompresses & lies on P-256', S.length === 65 && S[0] === 0x04);
147
+
148
+ // 2. ECDH symmetry: client and Stick derive the SAME key.
149
+ // The real S has an unknown private; model it with a generated keypair.
150
+ const staticKp = crypto.createECDH('prime256v1'); staticKp.generateKeys(); // (s, S)
151
+ const stickEph = crypto.createECDH('prime256v1'); stickEph.generateKeys(); // (e, Q)
152
+ const Spub = staticKp.getPublicKey(null, 'uncompressed');
153
+ const Qpub = stickEph.getPublicKey(null, 'uncompressed');
154
+
155
+ const client = makeEphemeral(); // (d, our)
156
+ const ourPub = client.getPublicKey(null, 'uncompressed');
157
+
158
+ // client side: X(d·S) XOR X(d·Q)
159
+ const clientKey = (() => {
160
+ const x1 = pad32(client.computeSecret(Spub));
161
+ const x2 = pad32(client.computeSecret(Qpub));
162
+ const o = Buffer.alloc(32);
163
+ for (let i = 0; i < 32; i++) o[i] = x1[i] ^ x2[i];
164
+ return rev(o);
165
+ })();
166
+ // stick side: X(s·our) XOR X(e·our)
167
+ const stickKey = (() => {
168
+ const x1 = pad32(staticKp.computeSecret(ourPub));
169
+ const x2 = pad32(stickEph.computeSecret(ourPub));
170
+ const o = Buffer.alloc(32);
171
+ for (let i = 0; i < 32; i++) o[i] = x1[i] ^ x2[i];
172
+ return rev(o);
173
+ })();
174
+ check('client & Stick derive identical key (ECDH symmetry)', clientKey.equals(stickKey));
175
+ check('derived key is 32 bytes (AES-256)', clientKey.length === 32);
176
+
177
+ // 3. wire round-trip
178
+ const wire = pointToWire(Qpub);
179
+ check('point ⇄ wire round-trips', wireToPoint(wire).equals(Qpub) && wire.length === 64);
180
+
181
+ // 4. exercise deriveDmxKey() with the REAL static point
182
+ const k = deriveDmxKey(makeEphemeral(), Qpub);
183
+ check('deriveDmxKey() runs with real S, yields 32B', k.length === 32);
184
+
185
+ console.log(`\n${ok ? 'ALL TESTS PASSED' : 'TESTS FAILED'}`);
186
+ process.exit(ok ? 0 : 1);
187
+ }
188
+
189
+ if (import.meta.url === `file://${process.argv[1]}`) selfTest();
@@ -0,0 +1,127 @@
1
+ // Byte-by-byte diff of every 0x011c S->C reply across one or more pcaps.
2
+ // Identifies which byte positions are constant across sessions vs which
3
+ // vary -- so we know exactly which bytes inside that 258-byte payload
4
+ // HWM might be reading as session-state vs structure.
5
+ //
6
+ // node tools/diff-011c.mjs <pcap> [<pcap>...]
7
+
8
+ import fs from 'node:fs';
9
+
10
+ function ipFromEth(pkt) {
11
+ if (pkt.length < 14) return null;
12
+ let off = 12;
13
+ let eth = pkt.readUInt16BE(off);
14
+ for (let i = 0; i < 2 && eth === 0x8100; i++) {
15
+ off += 4; if (off + 2 > pkt.length) return null;
16
+ eth = pkt.readUInt16BE(off);
17
+ }
18
+ if (eth !== 0x0800) return null;
19
+ return pkt.subarray(off + 2);
20
+ }
21
+
22
+ function reassemble(segs) {
23
+ if (!segs.length) return Buffer.alloc(0);
24
+ segs.sort((a, b) => (a.seq - b.seq + 0x100000000) % 0x100000000);
25
+ const seen = new Set();
26
+ let base = segs[0].seq;
27
+ const out = [];
28
+ for (const s of segs) {
29
+ const k = `${s.seq}:${s.payload.length}`;
30
+ if (seen.has(k)) continue;
31
+ seen.add(k);
32
+ const off = (s.seq - base + 0x100000000) % 0x100000000;
33
+ while (out.length < off + s.payload.length) out.push(0);
34
+ for (let i = 0; i < s.payload.length; i++) out[off + i] = s.payload[i];
35
+ }
36
+ return Buffer.from(out);
37
+ }
38
+
39
+ function extractReplies(pcapPath) {
40
+ const buf = fs.readFileSync(pcapPath);
41
+ const magic = buf.readUInt32LE(0);
42
+ const le = magic === 0xa1b2c3d4;
43
+ if (!le && magic !== 0xd4c3b2a1) return [];
44
+ const streams = new Map();
45
+ let off = 24;
46
+ while (off + 16 <= buf.length) {
47
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
48
+ off += 16;
49
+ if (off + capLen > buf.length) break;
50
+ const pkt = buf.subarray(off, off + capLen); off += capLen;
51
+ const ip = ipFromEth(pkt);
52
+ if (!ip || ip.length < 20 || ((ip[0] >> 4) & 0xf) !== 4) continue;
53
+ const ihl = (ip[0] & 0x0f) * 4;
54
+ if (ip[9] !== 6) continue;
55
+ const tcp = ip.subarray(ihl);
56
+ const sp = tcp.readUInt16BE(0), dp = tcp.readUInt16BE(2);
57
+ if (sp !== 2431) continue; // S->C only
58
+ const seq = tcp.readUInt32BE(4);
59
+ const dataOff = (tcp[12] >> 4) * 4;
60
+ const payload = tcp.subarray(dataOff);
61
+ if (!payload.length) continue;
62
+ const src = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${sp}`;
63
+ const dst = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}:${dp}`;
64
+ const k = [src, dst].sort().join('<->');
65
+ if (!streams.has(k)) streams.set(k, []);
66
+ streams.get(k).push({ seq, payload });
67
+ }
68
+ const replies = [];
69
+ for (const [k, segs] of streams) {
70
+ const reassembled = reassemble(segs);
71
+ for (let i = 0; i + 10 <= reassembled.length; i++) {
72
+ const m = reassembled.subarray(i, i + 8).toString();
73
+ if (m !== 'Stick_3A' && m !== 'LSAG_ALL') continue;
74
+ if (reassembled.readUInt16LE(i + 8) !== 0x011c) continue;
75
+ // length-prefix at +0x14
76
+ if (i + 0x18 > reassembled.length) continue;
77
+ const len = reassembled.readUInt32LE(i + 0x14);
78
+ if (len < 0x18 || len > 0x400 || i + len > reassembled.length) continue;
79
+ replies.push({ pcap: pcapPath, stream: k, msg: reassembled.subarray(i, i + len) });
80
+ }
81
+ }
82
+ return replies;
83
+ }
84
+
85
+ const all = [];
86
+ for (const pcap of process.argv.slice(2)) {
87
+ const reps = extractReplies(pcap);
88
+ console.log(`${pcap}: ${reps.length} 0x011c replies`);
89
+ all.push(...reps);
90
+ }
91
+ if (all.length < 2) { console.error('need >= 2 replies to diff'); process.exit(1); }
92
+
93
+ const L = Math.min(...all.map((r) => r.msg.length));
94
+ console.log(`\nshortest = ${L}B; diffing ${all.length} replies position-by-position\n`);
95
+
96
+ // Constant vs variable map.
97
+ const ranges = []; // [{start,end,kind: 'const'|'var', vals?}]
98
+ let cur = null;
99
+ for (let i = 0; i < L; i++) {
100
+ const vals = new Set(all.map((r) => r.msg[i]));
101
+ const kind = vals.size === 1 ? 'const' : 'var';
102
+ if (!cur || cur.kind !== kind) {
103
+ if (cur) ranges.push(cur);
104
+ cur = { start: i, end: i, kind, vals };
105
+ } else {
106
+ cur.end = i;
107
+ if (kind === 'const') cur.vals = vals;
108
+ }
109
+ }
110
+ if (cur) ranges.push(cur);
111
+
112
+ console.log('=== regions ===');
113
+ for (const r of ranges) {
114
+ if (r.kind === 'const') {
115
+ const v = all[0].msg.subarray(r.start, r.end + 1).toString('hex');
116
+ console.log(` +0x${r.start.toString(16).padStart(3,'0')}..+0x${r.end.toString(16).padStart(3,'0')} (${r.end-r.start+1}B) CONST = ${v}`);
117
+ } else {
118
+ console.log(` +0x${r.start.toString(16).padStart(3,'0')}..+0x${r.end.toString(16).padStart(3,'0')} (${r.end-r.start+1}B) variable`);
119
+ }
120
+ }
121
+
122
+ // Side-by-side first 64 bytes of payload (after the 0x1c header).
123
+ console.log('\n=== first 64B of payload (offset 0x1c..0x5c) per session ===');
124
+ for (const r of all) {
125
+ const tag = r.pcap.split('/').pop() + ' tok=' + r.msg.readUInt32LE(0x0a);
126
+ console.log(` ${tag.padEnd(34)} ${r.msg.subarray(0x1c, 0x5c).toString('hex')}`);
127
+ }
@@ -0,0 +1,54 @@
1
+ // Quick summary of every packet in a pcap, with TCP/UDP demux and payload preview.
2
+
3
+ import fs from 'node:fs';
4
+
5
+ const [, , pcapPath] = process.argv;
6
+ if (!pcapPath) { console.error('usage: node dump-pcap-summary.mjs <file.pcap>'); process.exit(1); }
7
+
8
+ const buf = fs.readFileSync(pcapPath);
9
+ const magic = buf.readUInt32LE(0);
10
+ const le = magic === 0xa1b2c3d4 ? true : magic === 0xd4c3b2a1 ? false : null;
11
+ if (le === null) { console.error('not a pcap'); process.exit(2); }
12
+ const linkType = le ? buf.readUInt32LE(20) : buf.readUInt32BE(20);
13
+ const ethSkip = linkType === 1 ? 14 : 4;
14
+
15
+ let off = 24, idx = 0;
16
+ while (off + 16 <= buf.length) {
17
+ const tsSec = le ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
18
+ const tsUsec = le ? buf.readUInt32LE(off + 4) : buf.readUInt32BE(off + 4);
19
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
20
+ off += 16;
21
+ if (off + capLen > buf.length) break;
22
+ const pkt = buf.subarray(off, off + capLen);
23
+ off += capLen;
24
+ idx++;
25
+
26
+ if (pkt.length < ethSkip + 20) continue;
27
+ const ip = pkt.subarray(ethSkip);
28
+ if (((ip[0] >> 4) & 0xf) !== 4) continue;
29
+ const ihl = (ip[0] & 0x0f) * 4;
30
+ const proto = ip[9];
31
+ const srcIp = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}`;
32
+ const dstIp = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}`;
33
+ const l4 = ip.subarray(ihl);
34
+ const ts = `${tsSec}.${String(tsUsec).padStart(6,'0')}`;
35
+ if (proto === 6) { // TCP
36
+ const sp = l4.readUInt16BE(0), dp = l4.readUInt16BE(2);
37
+ const flags = l4[13];
38
+ const fStr = [
39
+ flags&0x02?'S':'', flags&0x10?'A':'', flags&0x08?'P':'',
40
+ flags&0x01?'F':'', flags&0x04?'R':''
41
+ ].join('');
42
+ const dataOff = (l4[12] >> 4) * 4;
43
+ const payload = l4.subarray(dataOff);
44
+ console.log(`#${idx} ${ts} TCP ${srcIp}:${sp} -> ${dstIp}:${dp} [${fStr}] payload=${payload.length}B${payload.length?' '+payload.subarray(0,40).toString('hex'):''}`);
45
+ } else if (proto === 17) { // UDP
46
+ const sp = l4.readUInt16BE(0), dp = l4.readUInt16BE(2);
47
+ const ulen = l4.readUInt16BE(4) - 8;
48
+ const payload = l4.subarray(8, 8+ulen);
49
+ const preview = payload.length > 32 ? payload.subarray(0,32).toString('hex')+'...' : payload.toString('hex');
50
+ console.log(`#${idx} ${ts} UDP ${srcIp}:${sp} -> ${dstIp}:${dp} payload=${ulen}B ${preview}`);
51
+ } else {
52
+ console.log(`#${idx} ${ts} proto=${proto} ${srcIp}->${dstIp}`);
53
+ }
54
+ }
@@ -0,0 +1,123 @@
1
+ // TCP stream reassembler for the Stick-DE3 TCP/2431 control channel.
2
+ //
3
+ // Pulls ALL payload bytes from both directions, in order, and prints them
4
+ // as labelled message frames. The Stick's protocol has a clean "magic +
5
+ // opcode" header on each application message, so we can chunk by parsing
6
+ // the magic alone.
7
+ //
8
+ // Output: one block per application message:
9
+ // [time] DIR len=N magic+opcode header + full hex dump
10
+
11
+ import fs from 'node:fs';
12
+
13
+ const [, , pcapPath] = process.argv;
14
+ if (!pcapPath) { console.error('usage: node dump-tcp-stream.mjs <file.pcap>'); process.exit(1); }
15
+
16
+ const STICK_IP = process.env.STICK_IP || '192.168.96.2';
17
+ const STICK_PORT = Number(process.env.STICK_PORT || 2431);
18
+
19
+ const buf = fs.readFileSync(pcapPath);
20
+ const magic = buf.readUInt32LE(0);
21
+ const le = magic === 0xa1b2c3d4 ? true : magic === 0xd4c3b2a1 ? false : null;
22
+ if (le === null) { console.error('not a pcap'); process.exit(2); }
23
+ const linkType = le ? buf.readUInt32LE(20) : buf.readUInt32BE(20);
24
+ const ethSkip = linkType === 1 ? 14 : 4;
25
+
26
+ // Gather all TCP payload chunks in order with direction + timestamp.
27
+ const chunks = [];
28
+ let off = 24;
29
+ while (off + 16 <= buf.length) {
30
+ const tsSec = le ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
31
+ const tsUsec = le ? buf.readUInt32LE(off + 4) : buf.readUInt32BE(off + 4);
32
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
33
+ off += 16;
34
+ if (off + capLen > buf.length) break;
35
+ const pkt = buf.subarray(off, off + capLen);
36
+ off += capLen;
37
+ if (pkt.length < ethSkip + 20) continue;
38
+ const ip = pkt.subarray(ethSkip);
39
+ if (((ip[0] >> 4) & 0xf) !== 4) continue;
40
+ const ihl = (ip[0] & 0x0f) * 4;
41
+ const proto = ip[9];
42
+ if (proto !== 6) continue;
43
+ const srcIp = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}`;
44
+ const dstIp = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}`;
45
+ const tcp = ip.subarray(ihl);
46
+ if (tcp.length < 20) continue;
47
+ const sp = tcp.readUInt16BE(0), dp = tcp.readUInt16BE(2);
48
+ const dataOff = (tcp[12] >> 4) * 4;
49
+ const payload = tcp.subarray(dataOff);
50
+ if (payload.length === 0) continue;
51
+ if (!((srcIp === STICK_IP && sp === STICK_PORT) || (dstIp === STICK_IP && dp === STICK_PORT))) continue;
52
+ const dir = (srcIp === STICK_IP) ? 'S→C' : 'C→S';
53
+ chunks.push({ ts: `${tsSec}.${String(tsUsec).padStart(6,'0')}`, dir, payload });
54
+ }
55
+
56
+ // Re-chunk by application message: each message starts with 8-byte magic
57
+ // "LSAG_ALL" or "Stick_3A". Concatenate contiguous same-direction chunks,
58
+ // then split by magic occurrences.
59
+ function chunkByMagic(data) {
60
+ const out = [];
61
+ let i = 0;
62
+ while (i < data.length) {
63
+ // Find next magic at i (must be at start of message)
64
+ const m1 = data.indexOf(Buffer.from('LSAG_ALL'), i);
65
+ const m2 = data.indexOf(Buffer.from('Stick_3A'), i);
66
+ const next = (m1 === -1) ? m2 : (m2 === -1) ? m1 : Math.min(m1, m2);
67
+ if (next === -1) { out.push(data.subarray(i)); break; }
68
+ if (next > i) out.push(data.subarray(i, next));
69
+ // Find end = next magic after `next`
70
+ const a = data.indexOf(Buffer.from('LSAG_ALL'), next + 8);
71
+ const b = data.indexOf(Buffer.from('Stick_3A'), next + 8);
72
+ const end = (a === -1) ? b : (b === -1) ? a : Math.min(a, b);
73
+ if (end === -1) { out.push(data.subarray(next)); break; }
74
+ out.push(data.subarray(next, end));
75
+ i = end;
76
+ }
77
+ return out;
78
+ }
79
+
80
+ // Run the dir state machine
81
+ let stream = { 'C→S': Buffer.alloc(0), 'S→C': Buffer.alloc(0) };
82
+ let msgCount = 0;
83
+ for (const c of chunks) {
84
+ stream[c.dir] = Buffer.concat([stream[c.dir], c.payload]);
85
+ // Try to consume complete messages from this direction
86
+ const msgs = chunkByMagic(stream[c.dir]);
87
+ if (msgs.length > 1) {
88
+ for (let i = 0; i < msgs.length - 1; i++) {
89
+ msgCount++;
90
+ const m = msgs[i];
91
+ const magic = m.length >= 8 ? m.subarray(0, 8).toString('latin1') : '???';
92
+ const opcode = m.length >= 10 ? m.readUInt16LE(8).toString(16).padStart(4,'0') : '?';
93
+ console.log(`[${c.ts}] msg#${msgCount} ${c.dir} ${m.length}B magic="${magic}" opcode=0x${opcode}`);
94
+ // Full hex dump in rows of 16
95
+ for (let off = 0; off < m.length; off += 16) {
96
+ const row = m.subarray(off, off + 16);
97
+ const hex = Array.from(row).map(b => b.toString(16).padStart(2, '0')).join(' ');
98
+ const ascii = Array.from(row).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join('');
99
+ console.log(` ${off.toString(16).padStart(4,'0')}: ${hex.padEnd(48)} |${ascii}|`);
100
+ }
101
+ console.log();
102
+ }
103
+ stream[c.dir] = msgs[msgs.length - 1];
104
+ }
105
+ }
106
+ // Flush any leftover partial messages
107
+ for (const dir of ['C→S', 'S→C']) {
108
+ if (stream[dir].length) {
109
+ msgCount++;
110
+ const m = stream[dir];
111
+ const magic = m.length >= 8 ? m.subarray(0, 8).toString('latin1') : '???';
112
+ const opcode = m.length >= 10 ? m.readUInt16LE(8).toString(16).padStart(4,'0') : '?';
113
+ console.log(`[flush] msg#${msgCount} ${dir} ${m.length}B magic="${magic}" opcode=0x${opcode}`);
114
+ for (let off = 0; off < m.length; off += 16) {
115
+ const row = m.subarray(off, off + 16);
116
+ const hex = Array.from(row).map(b => b.toString(16).padStart(2, '0')).join(' ');
117
+ const ascii = Array.from(row).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join('');
118
+ console.log(` ${off.toString(16).padStart(4,'0')}: ${hex.padEnd(48)} |${ascii}|`);
119
+ }
120
+ console.log();
121
+ }
122
+ }
123
+ console.log(`# total messages: ${msgCount}`);
@@ -0,0 +1,117 @@
1
+ // Extract every TCP/2431 message with opcode 0x011c from a pcap.
2
+ //
3
+ // Both directions: client->Stick (request body, if any) and Stick->client
4
+ // (the 263-byte session-unique reply blob we don't decode). Strips 802.1Q
5
+ // tags so it works on the UniFi-mirror pcaps as well as laptop captures.
6
+ //
7
+ // node tools/extract-011c.mjs <file.pcap>
8
+
9
+ import fs from 'node:fs';
10
+
11
+ const [, , pcapPath] = process.argv;
12
+ if (!pcapPath) { console.error('usage: node tools/extract-011c.mjs <file.pcap>'); process.exit(1); }
13
+
14
+ const buf = fs.readFileSync(pcapPath);
15
+ const magic = buf.readUInt32LE(0);
16
+ const le = magic === 0xa1b2c3d4;
17
+ if (!le && magic !== 0xd4c3b2a1) { console.error('bad pcap'); process.exit(2); }
18
+
19
+ // IP-level walker that handles 802.1Q-tagged frames too.
20
+ function ipFromEth(pkt) {
21
+ if (pkt.length < 14) return null;
22
+ let off = 12;
23
+ let eth = pkt.readUInt16BE(off);
24
+ // walk up to 2 VLAN tags
25
+ for (let i = 0; i < 2 && eth === 0x8100; i++) {
26
+ off += 4;
27
+ if (off + 2 > pkt.length) return null;
28
+ eth = pkt.readUInt16BE(off);
29
+ }
30
+ if (eth !== 0x0800) return null;
31
+ return pkt.subarray(off + 2);
32
+ }
33
+
34
+ // Reassemble per-connection TCP streams so an 0x011c reply that spans
35
+ // segments still surfaces as one message.
36
+ const streams = new Map(); // key -> { c2s: {seq0,buf}, s2c: {seq0,buf} }
37
+
38
+ let off = 24;
39
+ while (off + 16 <= buf.length) {
40
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
41
+ const tsSec = le ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
42
+ const tsUsec = le ? buf.readUInt32LE(off + 4) : buf.readUInt32BE(off + 4);
43
+ off += 16;
44
+ if (off + capLen > buf.length) break;
45
+ const pkt = buf.subarray(off, off + capLen);
46
+ off += capLen;
47
+ const ip = ipFromEth(pkt);
48
+ if (!ip || ip.length < 20) continue;
49
+ if (((ip[0] >> 4) & 0xf) !== 4) continue;
50
+ const ihl = (ip[0] & 0x0f) * 4;
51
+ if (ip[9] !== 6) continue; // TCP only
52
+ const tcp = ip.subarray(ihl);
53
+ const sp = tcp.readUInt16BE(0), dp = tcp.readUInt16BE(2);
54
+ if (sp !== 2431 && dp !== 2431) continue;
55
+ const seq = tcp.readUInt32BE(4);
56
+ const dataOff = (tcp[12] >> 4) * 4;
57
+ const payload = tcp.subarray(dataOff);
58
+ if (payload.length === 0) continue;
59
+ const src = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${sp}`;
60
+ const dst = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}:${dp}`;
61
+ const cs = [src, dst].sort().join(' <-> ');
62
+ if (!streams.has(cs)) streams.set(cs, { c2s: [], s2c: [], t0: tsSec + tsUsec / 1e6 });
63
+ const dir = sp === 2431 ? 's2c' : 'c2s';
64
+ streams.get(cs)[dir].push({ seq, payload, t: tsSec + tsUsec / 1e6 });
65
+ }
66
+
67
+ function reassemble(segments) {
68
+ if (!segments.length) return Buffer.alloc(0);
69
+ segments.sort((a, b) => (a.seq - b.seq + 0x100000000) % 0x100000000);
70
+ // De-dup retransmits (same seq, same length).
71
+ const seen = new Set();
72
+ const parts = [];
73
+ let base = segments[0].seq;
74
+ let cursor = base;
75
+ for (const s of segments) {
76
+ const k = `${s.seq}:${s.payload.length}`;
77
+ if (seen.has(k)) continue;
78
+ seen.add(k);
79
+ const off = (s.seq - base + 0x100000000) % 0x100000000;
80
+ while (parts.length < off + s.payload.length) parts.push(0);
81
+ for (let i = 0; i < s.payload.length; i++) parts[off + i] = s.payload[i];
82
+ }
83
+ return Buffer.from(parts);
84
+ }
85
+
86
+ // Walk a reassembled stream looking for messages by the 8-byte magic
87
+ // (Stick_3A or LSAG_ALL) followed by 2-byte LE opcode == 0x011c.
88
+ function scan(direction, key, segs) {
89
+ const buf = reassemble(segs);
90
+ for (let i = 0; i + 10 <= buf.length; i++) {
91
+ const m = buf.subarray(i, i + 8).toString();
92
+ if (m !== 'Stick_3A' && m !== 'LSAG_ALL') continue;
93
+ const op = buf.readUInt16LE(i + 8);
94
+ if (op !== 0x011c) continue;
95
+ // We don't know the length-prefix scheme universally, but we can
96
+ // print everything from this magic to the next magic (or end).
97
+ let j = i + 10;
98
+ while (j + 8 <= buf.length) {
99
+ const mm = buf.subarray(j, j + 8).toString();
100
+ if (mm === 'Stick_3A' || mm === 'LSAG_ALL') break;
101
+ j++;
102
+ }
103
+ const msg = buf.subarray(i, j);
104
+ console.log(`\n--- ${key} ${direction} opcode=0x011c len=${msg.length}B ---`);
105
+ console.log(`magic=${m} token=${buf.readUInt32LE(i + 10)} bytes:`);
106
+ for (let k = 0; k < msg.length; k += 32) {
107
+ const row = msg.subarray(k, Math.min(k + 32, msg.length));
108
+ console.log(` ${k.toString().padStart(4)} : ${row.toString('hex')}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ console.log(`pcap ${pcapPath}: ${streams.size} TCP/2431 stream(s)`);
114
+ for (const [k, s] of streams) {
115
+ scan('S->C', k, s.s2c);
116
+ scan('C->S', k, s.c2s);
117
+ }