@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,78 @@
1
+ // Extract one 576-byte DMX payload from a tcpdump pcap file.
2
+ //
3
+ // pcap format (libpcap, little-endian):
4
+ // 24-byte global header (magic d4c3b2a1 or a1b2c3d4)
5
+ // per-packet records:
6
+ // 16-byte record header (ts_sec, ts_usec, cap_len, orig_len)
7
+ // <cap_len> bytes of raw frame (ethernet+IP+UDP+payload)
8
+ //
9
+ // We look for any packet whose IP/UDP demux says: dst=STICK_IP:STICK_PORT
10
+ // and UDP payload length == 576.
11
+
12
+ import fs from 'node:fs';
13
+
14
+ const STICK_IP = process.env.STICK_IP || '192.168.96.2';
15
+ const STICK_PORT = Number(process.env.STICK_PORT || 2431);
16
+
17
+ const [, , pcapPath, outPath] = process.argv;
18
+ if (!pcapPath || !outPath) {
19
+ console.error('usage: node tools/extract-frame.mjs <input.pcap> <output.bin>');
20
+ process.exit(1);
21
+ }
22
+
23
+ const buf = fs.readFileSync(pcapPath);
24
+ if (buf.length < 24) { console.error('pcap too small'); process.exit(2); }
25
+
26
+ // Magic constant `0xa1b2c3d4` is written in the writer's native byte order.
27
+ // Reading the first 4 bytes as LE: 0xa1b2c3d4 -> file is LE
28
+ // 0xd4c3b2a1 -> file is BE
29
+ const magic = buf.readUInt32LE(0);
30
+ let le;
31
+ if (magic === 0xa1b2c3d4) le = true;
32
+ else if (magic === 0xd4c3b2a1) le = false;
33
+ else { console.error(`unknown pcap magic 0x${magic.toString(16)}`); process.exit(2); }
34
+
35
+ const linkType = le ? buf.readUInt32LE(20) : buf.readUInt32BE(20);
36
+ // linkType 1 = Ethernet, 0 = BSD loopback / NULL header (4 bytes)
37
+ const ethSkip = (linkType === 1) ? 14 : (linkType === 0 ? 4 : 14);
38
+
39
+ let off = 24;
40
+ let pkts = 0, matched = 0;
41
+ while (off + 16 <= buf.length) {
42
+ const tsSec = le ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
43
+ const tsUsec = le ? buf.readUInt32LE(off + 4) : buf.readUInt32BE(off + 4);
44
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
45
+ off += 16;
46
+ if (off + capLen > buf.length) break;
47
+ pkts++;
48
+ const pkt = buf.subarray(off, off + capLen);
49
+ off += capLen;
50
+
51
+ // Parse Ethernet/Null + IPv4 + UDP
52
+ if (pkt.length < ethSkip + 28) continue;
53
+ const ip = pkt.subarray(ethSkip);
54
+ const ihl = (ip[0] & 0x0f) * 4;
55
+ const proto = ip[9];
56
+ if (proto !== 17) continue; // not UDP
57
+
58
+ const srcIp = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}`;
59
+ const dstIp = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}`;
60
+ const udp = ip.subarray(ihl);
61
+ if (udp.length < 8) continue;
62
+ const srcPort = udp.readUInt16BE(0);
63
+ const dstPort = udp.readUInt16BE(2);
64
+ const udpLen = udp.readUInt16BE(4);
65
+ const payload = udp.subarray(8, udpLen);
66
+
67
+ if (dstIp === STICK_IP && dstPort === STICK_PORT) {
68
+ matched++;
69
+ console.error(` match #${matched}: ${srcIp}:${srcPort} -> ${dstIp}:${dstPort} payload=${payload.length}B`);
70
+ if (payload.length === 576) {
71
+ fs.writeFileSync(outPath, payload);
72
+ console.error(`OK: wrote 576-byte payload to ${outPath}`);
73
+ process.exit(0);
74
+ }
75
+ }
76
+ }
77
+ console.error(`scanned ${pkts} packets, ${matched} matched dst, no 576B payload found`);
78
+ process.exit(3);
@@ -0,0 +1,140 @@
1
+ // Brute-force search for the AES-256 key that decrypts the 0x011c reply.
2
+ //
3
+ // We have constraints on the plaintext:
4
+ // - 240 bytes = 15 AES-CBC blocks decrypts to 236-byte useful body + 4 padding
5
+ // - Padding is either PKCS#7 (`04 04 04 04`) or zero-fill (`00 00 00 00`)
6
+ // - Per HWM (FUN_1001676a0), plaintext has a self-consistency check:
7
+ // plaintext[0..4] == ~plaintext[0x64..0x68] (bitwise-complement)
8
+ //
9
+ // Strategy: walk every 32-byte aligned offset of the HardwareManager binary
10
+ // and try that 32 bytes as an AES-256 key against a captured (IV, ciphertext).
11
+ // Pre-filter by padding tail; secondary-filter by the complement check.
12
+ //
13
+ // node tools/find-011c-key.mjs <pcap> <hwm-binary>
14
+
15
+ import fs from 'node:fs';
16
+ import crypto from 'node:crypto';
17
+
18
+ const [, , pcapPath, hwmPath] = process.argv;
19
+ if (!pcapPath || !hwmPath) {
20
+ console.error('usage: node tools/find-011c-key.mjs <pcap> <hwm-binary>');
21
+ process.exit(1);
22
+ }
23
+
24
+ // --- extract one 0x011c reply ---------------------------------------------
25
+ function extractFirstReply(pcap) {
26
+ const buf = fs.readFileSync(pcap);
27
+ const le = buf.readUInt32LE(0) === 0xa1b2c3d4;
28
+ function ipFromEth(p) {
29
+ if (p.length < 14) return null;
30
+ let o = 12, e = p.readUInt16BE(o);
31
+ for (let i = 0; i < 2 && e === 0x8100; i++) { o += 4; if (o+2>p.length) return null; e = p.readUInt16BE(o); }
32
+ return e === 0x0800 ? p.subarray(o + 2) : null;
33
+ }
34
+ const streams = new Map();
35
+ let off = 24;
36
+ while (off + 16 <= buf.length) {
37
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
38
+ off += 16; if (off + capLen > buf.length) break;
39
+ const pkt = buf.subarray(off, off + capLen); off += capLen;
40
+ const ip = ipFromEth(pkt); if (!ip || ip.length < 20 || ((ip[0]>>4)&0xf)!==4) continue;
41
+ const ihl = (ip[0]&0x0f)*4; if (ip[9]!==6) continue;
42
+ const tcp = ip.subarray(ihl);
43
+ if (tcp.readUInt16BE(0) !== 2431) continue;
44
+ const seq = tcp.readUInt32BE(4);
45
+ const dataOff = (tcp[12]>>4)*4;
46
+ const payload = tcp.subarray(dataOff);
47
+ if (!payload.length) continue;
48
+ const key = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${tcp.readUInt16BE(0)}->${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}:${tcp.readUInt16BE(2)}`;
49
+ if (!streams.has(key)) streams.set(key, []);
50
+ streams.get(key).push({ seq, payload });
51
+ }
52
+ for (const segs of streams.values()) {
53
+ segs.sort((a,b)=>(a.seq-b.seq+0x100000000)%0x100000000);
54
+ const seen = new Set(); const out = [];
55
+ const base = segs[0].seq;
56
+ for (const s of segs) {
57
+ const k = `${s.seq}:${s.payload.length}`; if (seen.has(k)) continue; seen.add(k);
58
+ const o = (s.seq-base+0x100000000)%0x100000000;
59
+ while (out.length < o + s.payload.length) out.push(0);
60
+ for (let i = 0; i < s.payload.length; i++) out[o+i] = s.payload[i];
61
+ }
62
+ const r = Buffer.from(out);
63
+ for (let i = 0; i + 24 <= r.length; i++) {
64
+ if (r.subarray(i,i+8).toString() !== 'Stick_3A') continue;
65
+ if (r.readUInt16LE(i+8) !== 0x011c) continue;
66
+ const len = r.readUInt32LE(i+0x14);
67
+ if (len !== 284) continue;
68
+ const msg = r.subarray(i, i + 284);
69
+ return { iv: Buffer.from(msg.subarray(0x1a, 0x2a)), ct: Buffer.from(msg.subarray(0x2c, 0x2c + 240)) };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const reply = extractFirstReply(pcapPath);
76
+ if (!reply) { console.error('no 0x011c reply in', pcapPath); process.exit(2); }
77
+ console.log(`reply: IV=${reply.iv.toString('hex')}`);
78
+ console.log(`ct[0:32]=${reply.ct.subarray(0,32).toString('hex')}`);
79
+
80
+ // --- candidate key generator ----------------------------------------------
81
+ const bin = fs.readFileSync(hwmPath);
82
+ console.log(`binary: ${hwmPath} ${bin.length} bytes`);
83
+
84
+ // Try every 32-byte aligned offset. Also try byte-aligned (stride 1) for the
85
+ // last-pass. Reject keys that are obviously not credible (all zero, all FF).
86
+ function isBoring(k) {
87
+ let z = 0, ff = 0, distinct = new Set();
88
+ for (const b of k) { if (b===0) z++; if (b===0xff) ff++; distinct.add(b); }
89
+ if (z > 20 || ff > 20) return true;
90
+ if (distinct.size < 6) return true;
91
+ return false;
92
+ }
93
+
94
+ // Decrypt one block trial: returns Buffer or null on AES error
95
+ function dec(keyBuf, iv, ct) {
96
+ try {
97
+ const d = crypto.createDecipheriv('aes-256-cbc', keyBuf, iv);
98
+ d.setAutoPadding(false);
99
+ return Buffer.concat([d.update(ct), d.final()]);
100
+ } catch { return null; }
101
+ }
102
+
103
+ // Validation:
104
+ // 1. last 4 bytes of plaintext must be 04 04 04 04 OR 00 00 00 00
105
+ // 2. plaintext[0..4] XOR plaintext[0x64..0x68] == 0xff,0xff,0xff,0xff
106
+ function isValid(pt) {
107
+ if (!pt) return null;
108
+ const t0 = pt[236], t1 = pt[237], t2 = pt[238], t3 = pt[239];
109
+ const isPkcs7 = t0===4 && t1===4 && t2===4 && t3===4;
110
+ const isZero = t0===0 && t1===0 && t2===0 && t3===0;
111
+ if (!isPkcs7 && !isZero) return null;
112
+ // self-consistency check
113
+ let comp = true;
114
+ for (let i = 0; i < 4; i++) {
115
+ if (((pt[i] ^ pt[0x64+i]) & 0xff) !== 0xff) { comp = false; break; }
116
+ }
117
+ return { padKind: isPkcs7 ? 'pkcs7' : 'zero', comp };
118
+ }
119
+
120
+ console.log('\n=== pass 1: 32-byte aligned candidates ===');
121
+ let tested = 0, hits = 0;
122
+ for (let i = 0; i + 32 <= bin.length; i += 8) { // 8-byte alignment is reasonable
123
+ const k = bin.subarray(i, i + 32);
124
+ if (isBoring(k)) continue;
125
+ const pt = dec(k, reply.iv, reply.ct);
126
+ tested++;
127
+ const v = isValid(pt);
128
+ if (v) {
129
+ hits++;
130
+ console.log(` HIT @ 0x${i.toString(16)} pad=${v.padKind} comp=${v.comp}`);
131
+ console.log(` key = ${k.toString('hex')}`);
132
+ console.log(` pt[0..32] = ${pt.subarray(0,32).toString('hex')}`);
133
+ console.log(` pt[0x60..] = ${pt.subarray(0x60,0x80).toString('hex')}`);
134
+ if (v.comp) console.log(` *** complement check PASSED -- almost certainly the key ***`);
135
+ }
136
+ if (tested % 500000 === 0) {
137
+ console.log(` ... tested ${tested}, hits=${hits}`);
138
+ }
139
+ }
140
+ console.log(`done: tested=${tested} hits=${hits}`);
@@ -0,0 +1,153 @@
1
+ // Recover the AES-256-CBC first-block IV construction from a pcap of frames.
2
+ //
3
+ // With the key known, for every frame: D(key,C0) = IV XOR P0.
4
+ // If the DMX state is constant across the capture, P0 is the same in every
5
+ // frame, so for the CORRECT IV construction H(header):
6
+ // D(key,C0) XOR H(header) == P0 == the same constant in every frame.
7
+ // We test each candidate H and look for one that yields a frame-invariant
8
+ // residual. That residual is P0 (block-0 plaintext); H is the IV rule.
9
+ //
10
+ // Usage:
11
+ // node tools/find-iv.mjs <capture.pcap> [key-hex]
12
+
13
+ import * as crypto from 'node:crypto';
14
+ import * as fs from 'node:fs';
15
+
16
+ const DEFAULT_KEY =
17
+ '531e1b9f097be5de5785f5bf86473da34839fabb76676e95c0e4b808b42f8ac0';
18
+
19
+ const [, , pcapPath, keyArg] = process.argv;
20
+ if (!pcapPath) { console.error('usage: node tools/find-iv.mjs <capture.pcap> [key-hex]'); process.exit(1); }
21
+ const key = Buffer.from((keyArg || DEFAULT_KEY).replace(/\s/g, ''), 'hex');
22
+ if (key.length !== 32) { console.error('key must be 32 bytes'); process.exit(1); }
23
+
24
+ // --- parse pcap, collect every 576-byte UDP payload to the Stick ----------
25
+ const STICK_IP = process.env.STICK_IP || '192.168.96.2';
26
+ const STICK_PORT = Number(process.env.STICK_PORT || 2431);
27
+ const buf = fs.readFileSync(pcapPath);
28
+ const magic = buf.readUInt32LE(0);
29
+ const le = magic === 0xa1b2c3d4;
30
+ if (!le && magic !== 0xd4c3b2a1) { console.error('bad pcap magic'); process.exit(2); }
31
+ const linkType = le ? buf.readUInt32LE(20) : buf.readUInt32BE(20);
32
+ const ethSkip = linkType === 1 ? 14 : linkType === 0 ? 4 : 14;
33
+
34
+ const frames = [];
35
+ let off = 24;
36
+ while (off + 16 <= buf.length) {
37
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
38
+ off += 16;
39
+ if (off + capLen > buf.length) break;
40
+ const pkt = buf.subarray(off, off + capLen);
41
+ off += capLen;
42
+ if (pkt.length < ethSkip + 28) continue;
43
+ const ip = pkt.subarray(ethSkip);
44
+ if (ip[9] !== 17) continue; // not UDP
45
+ const dstIp = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}`;
46
+ const udp = ip.subarray((ip[0] & 0x0f) * 4);
47
+ if (udp.length < 8) continue;
48
+ const dstPort = udp.readUInt16BE(2);
49
+ const payload = udp.subarray(8, udp.readUInt16BE(4));
50
+ if (dstIp === STICK_IP && dstPort === STICK_PORT && payload.length === 576)
51
+ frames.push(Buffer.from(payload));
52
+ }
53
+ console.log(`parsed ${frames.length} encrypted 576-byte frames from ${pcapPath}`);
54
+ if (frames.length < 2) { console.error('need at least 2 frames'); process.exit(3); }
55
+
56
+ // --- D(key,C0) for each frame --------------------------------------------
57
+ function decBlock0(frame) {
58
+ const d = crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
59
+ d.setAutoPadding(false);
60
+ return d.update(frame.subarray(0x20, 0x30)); // D(key,C0), IV=0
61
+ }
62
+ function ecb(block) {
63
+ const c = crypto.createCipheriv('aes-256-ecb', key, null);
64
+ c.setAutoPadding(false);
65
+ return Buffer.concat([c.update(block), c.final()]);
66
+ }
67
+ function xor(a, b) {
68
+ const o = Buffer.alloc(16);
69
+ for (let i = 0; i < 16; i++) o[i] = a[i] ^ b[i];
70
+ return o;
71
+ }
72
+
73
+ const recs = frames.map(f => ({
74
+ hdr: f.subarray(0, 0x20),
75
+ nonce: Buffer.from(f.subarray(0x18, 0x20)),
76
+ fieldA: Buffer.from(f.subarray(0x0a, 0x12)),
77
+ dt0: decBlock0(f),
78
+ }));
79
+
80
+ // also reuse the per-session key material A||B from the Path C device dump,
81
+ // if present — the IV might be keyed on that instead.
82
+ let AB = null;
83
+ try {
84
+ const dev = fs.readFileSync(
85
+ fs.readdirSync('/tmp').filter(f => /^stick-pathC-blob-device_/.test(f))
86
+ .map(f => '/tmp/' + f)[0]);
87
+ // cipher_obj = device + 0x1618 ; A||B at cipher_obj + 0x28
88
+ AB = Buffer.from(dev.subarray(0x1618 + 0x28, 0x1618 + 0x28 + 32));
89
+ console.log(`loaded A||B from device dump: ${AB.toString('hex')}`);
90
+ } catch { /* optional */ }
91
+
92
+ const z8 = Buffer.alloc(8);
93
+ function blocks(r) {
94
+ const b = [
95
+ ['nonce|0', Buffer.concat([r.nonce, z8])],
96
+ ['0|nonce', Buffer.concat([z8, r.nonce])],
97
+ ['nonce|nonce', Buffer.concat([r.nonce, r.nonce])],
98
+ ['fieldA|nonce', Buffer.concat([r.fieldA, r.nonce])],
99
+ ['nonce|fieldA', Buffer.concat([r.nonce, r.fieldA])],
100
+ ['hdr[0:16]', Buffer.from(r.hdr.subarray(0, 16))],
101
+ ['hdr[16:32]', Buffer.from(r.hdr.subarray(16, 32))],
102
+ ['hdr[10:26]', Buffer.from(r.hdr.subarray(0x0a, 0x1a))],
103
+ ['nonce|nonceR', Buffer.concat([r.nonce, Buffer.from(r.nonce).reverse()])],
104
+ ];
105
+ return b;
106
+ }
107
+
108
+ // --- test each construction for a frame-invariant residual ---------------
109
+ function evaluate(label, hOf) {
110
+ const tally = new Map();
111
+ for (const r of recs) {
112
+ const resid = xor(r.dt0, hOf(r)).toString('hex');
113
+ tally.set(resid, (tally.get(resid) || 0) + 1);
114
+ }
115
+ let best = null;
116
+ for (const [resid, n] of tally)
117
+ if (!best || n > best.n) best = { resid, n };
118
+ return { label, frac: best.n / recs.length, resid: best.resid, distinct: tally.size };
119
+ }
120
+
121
+ const names = blocks(recs[0]).map(([n]) => n);
122
+ const results = [];
123
+ for (let i = 0; i < names.length; i++) {
124
+ const name = names[i];
125
+ results.push(evaluate(`IV = ${name}`, r => blocks(r)[i][1]));
126
+ results.push(evaluate(`IV = AES(key, ${name})`, r => ecb(blocks(r)[i][1])));
127
+ if (AB) {
128
+ const abEcb = (blk) => {
129
+ const c = crypto.createCipheriv('aes-256-ecb', AB, null);
130
+ c.setAutoPadding(false);
131
+ return Buffer.concat([c.update(blk), c.final()]);
132
+ };
133
+ results.push(evaluate(`IV = AES(A||B, ${name})`, r => abEcb(blocks(r)[i][1])));
134
+ }
135
+ }
136
+
137
+ results.sort((a, b) => b.frac - a.frac);
138
+ console.log();
139
+ console.log('IV construction — fraction of frames sharing one residual (=P0):');
140
+ for (const r of results.slice(0, 12))
141
+ console.log(` ${(r.frac * 100).toFixed(1).padStart(5)}% ${r.label.padEnd(26)}` +
142
+ ` ${r.frac > 0.8 ? 'P0=' + r.resid : `(${r.distinct} distinct)`}`);
143
+
144
+ console.log();
145
+ const win = results[0];
146
+ if (win.frac > 0.8) {
147
+ console.log(`✅ IV CONSTRUCTION: ${win.label}`);
148
+ console.log(` block-0 plaintext P0 = ${win.resid}`);
149
+ } else {
150
+ console.log('⚠️ no construction gives a frame-invariant residual.');
151
+ console.log(' Either the DMX state changed during capture, or the IV uses');
152
+ console.log(' inputs not tested here. The residual counts above hint which.');
153
+ }
@@ -0,0 +1,136 @@
1
+ # Stick-DE3 DMX cipher RE — Ghidra static analysis
2
+
3
+ Headless pyghidra scripts that reverse-engineer the DMX cipher in the
4
+ **2024-03-21 ESA2 HardwareManager** binary at:
5
+
6
+ ```
7
+ /Applications/ESA2/HardwareManager/HardwareManager.app/Contents/MacOS/HardwareManager
8
+ ```
9
+
10
+ Static RE *replaces* the prior interactive-lldb attempts (see
11
+ `memory/stick-aes-symbolized-breakthrough.md` for what didn't work, and
12
+ `memory/stick-cipher-is-stream-not-aes.md` for what's now known).
13
+
14
+ ## TL;DR — the cipher IS AES-128 (with mode dispatch)
15
+
16
+ The Stick3 DMX cipher is **AES-128** (custom-inlined, not Gladman), running
17
+ in CBC or CFB mode depending on a flag in the cipher state. Earlier in this
18
+ RE session I briefly concluded "stream cipher, not AES" — that was wrong;
19
+ I had been looking at a sibling class (`XHL_DasEccStreamCryptography`, used
20
+ for USB devices) whose encrypt path uses a small-state stream cipher. The
21
+ *Stick3 ANet* path doesn't go through that. The real AES path is via
22
+ `FUN_100107950` → `FUN_1003f6790` (CBC) / `FUN_1003f5140` (CFB) → block
23
+ cipher at `FUN_1003f5980` / `FUN_1003f4690` with S-box at `DAT_1007c0010`.
24
+
25
+ 576-byte DMX frame format:
26
+
27
+ ```
28
+ +0x00 (8B) session_magic table @ DAT_100d11a90 + idx*0x0E
29
+ +0x08 (2B) 0x0019 constant
30
+ +0x0A (8B) 8-byte from vtable slot 0x78
31
+ +0x12 (2B) port16
32
+ +0x14 (2B) channel count (≤512)
33
+ +0x16 (1B) 100 constant
34
+ +0x17 (1B) seq_counter++
35
+ +0x18 (8B) 8 random bytes nonce / first-block IV seed
36
+ +0x20 (544B) ENCRYPTED AES-128 (CBC or CFB) — 34 blocks of 16 bytes
37
+ ```
38
+
39
+ ## Key addresses
40
+
41
+ | Addr | Function |
42
+ |-------------|------------------------------------------------------|
43
+ | 0x10006C8A0 | `DmxWidget::onTimerSendAndReceive` (Qt timer slot) |
44
+ | 0x100074090 | `DmxUniversePage::onTimerSendReceive` |
45
+ | 0x100073BD0 | `DmxUniversePage::sendDmx` (QtConcurrent worker fn) |
46
+ | 0x1001a9d50 | `Stick3CryptDmxUniverse` "send DMX" (vt[22] primary) |
47
+ | 0x1001eeac0 | Frame-build wrapper |
48
+ | 0x1001ee990 | Build encrypted frame |
49
+ | **0x100107950** | **Real encrypt entry** (mode-dispatch CBC vs CFB) |
50
+ | 0x1003f5140 | AES-CFB outer (uses IV at +0xB0) |
51
+ | **0x1003f4690** | **AES-128 block cipher (CFB path, 1153 B)** |
52
+ | 0x1003f6790 | AES-CBC outer (chains with previous ciphertext) |
53
+ | **0x1003f5980** | **AES-128 block cipher (CBC path, 1872 B)** |
54
+ | **DAT_1007c0010** | **AES S-box** (256 bytes) |
55
+ | 0x1001b0340 | `XHL_UdpSocket::send` |
56
+ | 0x100677060 | `sendto` wrapper |
57
+
58
+ The earlier "stream cipher" addresses (`FUN_100108200`, `FUN_1005e0530`) are
59
+ for `XHL_DasEccStreamCryptography` — a sibling class for USB devices, NOT
60
+ on the Stick3 DMX path.
61
+
62
+ Typeinfo addresses (for further vtable hunting via `dyld_info -fixups`):
63
+
64
+ | Class | TypeInfo @ |
65
+ |--------------------------------|----------------|
66
+ | XHL_Stick3CryptDmxUniverse | 0x100970DC0 |
67
+ | XHL_DasEccStreamCryptography | 0x100852080 |
68
+ | XHL_DasEccAesCryptography | 0x100851ED8 |
69
+ | XHL_AesOStream | 0x100851C48 |
70
+
71
+ ## Reproduce
72
+
73
+ ```bash
74
+ # Install (one-time)
75
+ brew install ghidra
76
+
77
+ # Run any script (-noanalysis once initial import has been done)
78
+ /opt/homebrew/Cellar/ghidra/12.1/bin/pyghidraRun -H \
79
+ tools/ghidra HwmESA2 \
80
+ -import /Applications/ESA2/HardwareManager/HardwareManager.app/Contents/MacOS/HardwareManager \
81
+ -scriptPath tools/ghidra \
82
+ -postScript dump-anchors.py
83
+ ```
84
+
85
+ After the first run, omit `-import` and add `-process HardwareManager
86
+ -noanalysis` to re-run scripts against the saved project (fast).
87
+
88
+ ## Scripts
89
+
90
+ - `dump-anchors.py` — dump every named DMX/AES anchor function (Gladman, sendto, RTTI strings)
91
+ - `find-cipher.py` — chase XHL_UdpSocket::sendto callers and any RTTI vtable
92
+ - `find-vtables.py` — scan for typeinfo bytes in `__const` (mostly empty: Mach-O chained fixups)
93
+ - `string-walk.py` — find typeinfo-name strings then follow their data xrefs
94
+ - `find-stick3-crypto.py` — search Ghidra symbols by class-name keyword (mostly empty)
95
+ - `dump-stick3-vtable.py` — dump every Stick3 primary + secondary vtable slot fn
96
+ - `dump-cipher.py` — dump the encrypt chain rooted at `FUN_1001a9d50`
97
+ - `dump-aes-candidates.py` — dump candidate encrypt fn vt[3] across classes
98
+
99
+ ## Side-channel: `dyld_info -fixups`
100
+
101
+ Mach-O chained fixups aren't materialized by Ghidra for unsymbolized
102
+ typeinfos. Use `xcrun dyld_info -fixups <binary>` to dump every rebase /
103
+ bind. Vtables for hidden classes are reached by grepping for rebases
104
+ targeting a known typeinfo struct address. Example:
105
+
106
+ ```bash
107
+ xcrun dyld_info -fixups <binary> | grep 'rebase 0x100970DC0$'
108
+ # Each result is a vtable's typeinfo_ptr field; vt[0] is +8.
109
+ ```
110
+
111
+ ## KDF — SOLVED & VERIFIED (2026-05-21)
112
+
113
+ The per-session AES-256 key derivation is fully recovered by static RE and
114
+ **verified end-to-end** against a real captured session.
115
+ Scripts `dump-kdf-chain.py`, `dump-curve.py`, `dump-secret.py` decompile the
116
+ chain `FUN_100178030 → FUN_100107650`. Result:
117
+
118
+ - **Curve = NIST P-256.** `p`, `n`, `G`, and the `b` constant all byte-match.
119
+ - **KDF:** `key = LE( X(d·S) XOR X(d·Q) )` — `d` ephemeral, `S` a hardcoded
120
+ static point (raw 33-byte SEC1 point at `0x1007b0490`), `Q` the Stick
121
+ pubkey from the opcode-0x0F handshake.
122
+ - **Reference impl:** `tools/derive-dmx-key.mjs` (passing self-test).
123
+ - **Proof:** `tools/verify-kdf.mjs` recomputes a real session's installed
124
+ AES key byte-for-byte (using an lldb-dumped `d` + the handshake pcap).
125
+
126
+ Ghidra shows the curve-constant DATs as zero — its `__const`/`__data` blocks
127
+ weren't materialized. Read them straight from the Mach-O file:
128
+ `file_offset = vmaddr − 0x100000000`. Full write-up:
129
+ `memory/stick-cipher-is-stream-not-aes.md`.
130
+
131
+ ## What's left
132
+
133
+ 1. **Session-magic table** — `DAT_100d11a90` is a table of 14-byte entries.
134
+ Decode it (frame `+0x00` magic + handshake validation).
135
+ 2. **Random nonce source** — `FUN_100108670` (likely `std::mt19937`).
136
+ Either reproduce or pick our own.