@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,143 @@
1
+ // Deep analysis of a Stick-DE3 pcap: TCP connections + per-connection opcode
2
+ // timeline, and UDP DMX-frame header statistics (fieldA / seq progression,
3
+ // the +0x12 port field, timing). Built to diff a real Hardware Manager
4
+ // session against our send_dmx run.
5
+ //
6
+ // node tools/analyze-pcap.mjs <file.pcap>
7
+
8
+ import fs from 'node:fs';
9
+
10
+ const [, , pcapPath] = process.argv;
11
+ if (!pcapPath) { console.error('usage: node tools/analyze-pcap.mjs <file.pcap>'); process.exit(1); }
12
+
13
+ const buf = fs.readFileSync(pcapPath);
14
+ const magic = buf.readUInt32LE(0);
15
+ const le = magic === 0xa1b2c3d4;
16
+ if (!le && magic !== 0xd4c3b2a1) { console.error('bad pcap'); process.exit(2); }
17
+ const linkType = le ? buf.readUInt32LE(20) : buf.readUInt32BE(20);
18
+ const ethSkip = linkType === 1 ? 14 : 4;
19
+
20
+ const pkts = [];
21
+ let off = 24;
22
+ while (off + 16 <= buf.length) {
23
+ const tsSec = le ? buf.readUInt32LE(off) : buf.readUInt32BE(off);
24
+ const tsUsec = le ? buf.readUInt32LE(off + 4) : buf.readUInt32BE(off + 4);
25
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
26
+ off += 16;
27
+ if (off + capLen > buf.length) break;
28
+ const pkt = buf.subarray(off, off + capLen);
29
+ off += capLen;
30
+ if (pkt.length < ethSkip + 20) continue;
31
+ const ip = pkt.subarray(ethSkip);
32
+ if (((ip[0] >> 4) & 0xf) !== 4) continue;
33
+ const ihl = (ip[0] & 0x0f) * 4;
34
+ const proto = ip[9];
35
+ const src = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}`;
36
+ const dst = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}`;
37
+ const l4 = ip.subarray(ihl);
38
+ const t = tsSec + tsUsec / 1e6;
39
+ if (proto === 6) {
40
+ const sp = l4.readUInt16BE(0), dp = l4.readUInt16BE(2);
41
+ const flags = l4[13];
42
+ const dataOff = (l4[12] >> 4) * 4;
43
+ pkts.push({ t, proto: 'TCP', src, dst, sp, dp, flags, payload: Buffer.from(l4.subarray(dataOff)) });
44
+ } else if (proto === 17) {
45
+ const sp = l4.readUInt16BE(0), dp = l4.readUInt16BE(2);
46
+ const ulen = l4.readUInt16BE(4) - 8;
47
+ pkts.push({ t, proto: 'UDP', src, dst, sp, dp, payload: Buffer.from(l4.subarray(8, 8 + ulen)) });
48
+ }
49
+ }
50
+
51
+ const t0 = pkts.length ? pkts[0].t : 0;
52
+ const rel = (t) => (t - t0).toFixed(3).padStart(9);
53
+
54
+ // ── TCP connections ─────────────────────────────────────────────────────────
55
+ console.log(`\n=== ${pcapPath} ===`);
56
+ console.log(`${pkts.length} IP packets, span ${(pkts[pkts.length-1].t - t0).toFixed(1)}s\n`);
57
+
58
+ const conns = new Map();
59
+ for (const p of pkts) {
60
+ if (p.proto !== 'TCP') continue;
61
+ const key = [p.src + ':' + p.sp, p.dst + ':' + p.dp].sort().join(' <-> ');
62
+ if (!conns.has(key)) conns.set(key, []);
63
+ conns.get(key).push(p);
64
+ }
65
+ console.log(`TCP connections: ${conns.size}`);
66
+ for (const [key, ps] of conns) {
67
+ const dur = ps[ps.length - 1].t - ps[0].t;
68
+ const dataP = ps.filter((p) => p.payload.length > 0);
69
+ console.log(`\n ${key} start=${rel(ps[0].t)}s dur=${dur.toFixed(3)}s pkts=${ps.length} (${dataP.length} w/ payload)`);
70
+ for (const p of dataP) {
71
+ const dir = p.sp === 2431 ? 'S->C' : 'C->S';
72
+ let op = '';
73
+ const pl = p.payload;
74
+ if (pl.length >= 10 && (pl.subarray(0,8).toString()==='Stick_3A' || pl.subarray(0,8).toString()==='LSAG_ALL')) {
75
+ op = `op=0x${pl.readUInt16LE(8).toString(16).padStart(4,'0')}`;
76
+ }
77
+ console.log(` ${rel(p.t)} ${dir} ${String(pl.length).padStart(4)}B ${op.padEnd(11)} ${pl.subarray(0,28).toString('hex')}`);
78
+ }
79
+ }
80
+
81
+ // ── UDP DMX frames ──────────────────────────────────────────────────────────
82
+ const dmx = pkts.filter((p) => p.proto === 'UDP' && p.payload.length === 576);
83
+ console.log(`\n\nUDP 576-byte DMX frames: ${dmx.length}`);
84
+ if (dmx.length) {
85
+ const f = dmx[0], h = f.payload;
86
+ console.log(` src ${f.src}:${f.sp} -> dst ${f.dst}:${f.dp}`);
87
+ console.log(` first frame header (32B): ${h.subarray(0,32).toString('hex')}`);
88
+ const fieldA0 = h.readUInt32LE(0x0a);
89
+ console.log(` +0x08 opcode = 0x${h.readUInt16LE(8).toString(16)}`);
90
+ console.log(` +0x0a fieldA = ${fieldA0} (0x${fieldA0.toString(16)}) [8B: ${h.subarray(0x0a,0x12).toString('hex')}]`);
91
+ console.log(` +0x12 port = ${h.readUInt16LE(0x12)}`);
92
+ console.log(` +0x14 chanCount = ${h.readUInt16LE(0x14)}`);
93
+ console.log(` +0x16 const = ${h[0x16]}`);
94
+ console.log(` +0x17 seq = ${h[0x17]}`);
95
+ console.log(` +0x18 nonce = ${h.subarray(0x18,0x20).toString('hex')}`);
96
+
97
+ // progressions
98
+ let dT = [], dA = [], dS = [], ports = new Set(), chans = new Set();
99
+ for (let i = 1; i < dmx.length; i++) {
100
+ dT.push(dmx[i].t - dmx[i-1].t);
101
+ let a = dmx[i].payload.readUInt32LE(0x0a) - dmx[i-1].payload.readUInt32LE(0x0a);
102
+ let s = dmx[i].payload[0x17] - dmx[i-1].payload[0x17];
103
+ if (s < 0) s += 256;
104
+ dA.push(a); dS.push(s);
105
+ }
106
+ for (const p of dmx) { ports.add(p.payload.readUInt16LE(0x12)); chans.add(p.payload.readUInt16LE(0x14)); }
107
+ const uniq = (arr) => [...new Set(arr)].sort((a,b)=>a-b);
108
+ const avg = (arr) => arr.reduce((s,x)=>s+x,0)/arr.length;
109
+ console.log(`\n fieldA delta/frame : ${uniq(dA).join(',')} (start ${fieldA0}, end ${dmx[dmx.length-1].payload.readUInt32LE(0x0a)})`);
110
+ console.log(` seq delta/frame : ${uniq(dS).join(',')}`);
111
+ console.log(` +0x12 port values : ${[...ports].join(',')}`);
112
+ console.log(` +0x14 chanCount : ${[...chans].join(',')}`);
113
+ console.log(` inter-frame gap : avg ${(avg(dT)*1000).toFixed(1)}ms (${(1/avg(dT)).toFixed(1)} Hz)`);
114
+ console.log(` first DMX frame at : ${rel(dmx[0].t)}s last at ${rel(dmx[dmx.length-1].t)}s`);
115
+
116
+ // ── unified-counter contiguity check ──────────────────────────────────────
117
+ // HWM draws fieldA (UDP) and the TCP token from ONE monotonic counter.
118
+ // Build a merged time-ordered list of (TCP token) + (UDP fieldA) and verify
119
+ // the UDP frames continue the TCP token sequence without going backwards.
120
+ const tcpTok = [];
121
+ for (const p of pkts) {
122
+ if (p.proto !== 'TCP' || p.payload.length < 18) continue;
123
+ const m = p.payload.subarray(0, 8).toString();
124
+ if (m !== 'Stick_3A' && m !== 'LSAG_ALL') continue;
125
+ if (p.sp === 2431) continue; // client->Stick only
126
+ const op = p.payload.readUInt16LE(8);
127
+ tcpTok.push({ t: p.t, kind: 'TCP', op, val: p.payload.readUInt32LE(10) });
128
+ }
129
+ const merged = [...tcpTok, ...dmx.map((p) => ({ t: p.t, kind: 'UDP', val: p.payload.readUInt32LE(0x0a) }))]
130
+ .sort((a, b) => a.t - b.t);
131
+ // last TCP token before the first UDP frame
132
+ const firstUdpT = dmx[0].t;
133
+ const tcpBefore = tcpTok.filter((x) => x.t < firstUdpT);
134
+ const lastTok = tcpBefore.length ? tcpBefore[tcpBefore.length - 1].val : null;
135
+ console.log(`\n unified counter:`);
136
+ console.log(` last TCP token before 1st UDP frame : ${lastTok}`);
137
+ console.log(` first UDP fieldA : ${dmx[0].payload.readUInt32LE(0x0a)}`);
138
+ let backwards = 0, prev = -1;
139
+ for (const e of merged) { if (e.val < prev) backwards++; prev = Math.max(prev, e.val); }
140
+ const contiguous = lastTok != null && dmx[0].payload.readUInt32LE(0x0a) === lastTok + 1;
141
+ console.log(` UDP fieldA == lastTCPtoken + 1 ? : ${contiguous ? 'YES' : 'NO'}`);
142
+ console.log(` backwards steps in merged sequence : ${backwards} ${backwards ? '<-- Stick will drop these' : '(monotonic OK)'}`);
143
+ }
@@ -0,0 +1,25 @@
1
+ # Capture the Stick-DE3 DMX AES-128 key from the SYMBOLIZED old HardwareManager.
2
+ #
3
+ # Target: 2024-03-21 ESA2 build (symbol-rich; NOT the stripped EsaPro2 one).
4
+ # Gladman AES: aes_encrypt_key128(const u8 *key, aes_encrypt_ctx *cx)
5
+ # x86_64 SysV at entry: $rdi = ptr to 16-byte raw key, $rsi = ctx
6
+ #
7
+ # ATTACH workflow (launch-under-lldb fails: Rosetta + hardened runtime):
8
+ # 1. Open ESA2 Hardware Manager normally. Do NOT connect the Stick yet.
9
+ # 2. PID=$(pgrep -f 'ESA2/HardwareManager/HardwareManager.app.*HardwareManager$')
10
+ # 3. lldb -p "$PID" -s tools/capture-aes-key.lldb
11
+ # 4. After it prints "continuing — connect the Stick now", in Hardware
12
+ # Manager connect to the Stick (192.168.96.2) and start DMX/live output.
13
+ #
14
+ # Each aes_encrypt_key128 hit prints the 16-byte key + caller backtrace
15
+ # (the backtrace = the per-session key-derivation chain for the plugin).
16
+ # IV not needed live: IV = aes_decrypt(C0) XOR P0 from a captured 576B frame.
17
+
18
+ break set -n aes_encrypt_key128
19
+ break command add -o "memory read --size 1 --format x --count 16 $rdi" -o "register read rdi rsi" -o "thread backtrace -c 16" -o "continue" 1
20
+
21
+ break set -n aes_encrypt_key
22
+ break command add -o "memory read --size 1 --format x --count 16 $rdi" -o "register read rdi rsi" -o "thread backtrace -c 16" -o "continue" 2
23
+
24
+ script print(">>> breakpoints armed; continuing — connect the Stick now <<<")
25
+ continue
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # Capture ONE encrypted DMX frame from the live Stick stream, then try to
3
+ # decrypt it with the hardcoded 33-byte secret discovered via Ghidra.
4
+ #
5
+ # Prereqs (already installed):
6
+ # - tcpdump (built-in on macOS)
7
+ # - node (for the pcap parser + decrypt test)
8
+ # That's it. No tshark, no tsx.
9
+ #
10
+ # Setup:
11
+ # 1. Open Hardware Manager and connect to the Stick at 192.168.96.2.
12
+ # 2. Open the DMX/fader screen so HWM is actively streaming live DMX.
13
+ # 3. Run this script. It needs sudo for tcpdump's raw socket.
14
+
15
+ set -euo pipefail
16
+ cd "$(dirname "$0")/.."
17
+
18
+ IFACE="${IFACE:-en0}"
19
+ STICK_IP="${STICK_IP:-192.168.96.2}"
20
+ STICK_PORT="${STICK_PORT:-2431}"
21
+ DUR="${DUR:-3}"
22
+ PCAP=/tmp/stick-dmx.pcap
23
+ FRAME=/tmp/stick-dmx-frame.bin
24
+
25
+ echo ">>> Capturing ${DUR}s of UDP→${STICK_IP}:${STICK_PORT} on ${IFACE}"
26
+ echo ">>> (HWM must be connected + streaming DMX right now)"
27
+ sudo tcpdump -i "$IFACE" -w "$PCAP" -G "$DUR" -W 1 \
28
+ "udp and host ${STICK_IP} and port ${STICK_PORT}" \
29
+ 2>/dev/null || true
30
+
31
+ if [[ ! -s "$PCAP" ]]; then
32
+ echo "!!! No packets captured. Is HWM connected and streaming?"
33
+ exit 2
34
+ fi
35
+
36
+ echo
37
+ echo ">>> pcap size: $(wc -c < "$PCAP") bytes"
38
+
39
+ echo
40
+ echo ">>> Extracting first 576-byte UDP payload"
41
+ STICK_IP="$STICK_IP" STICK_PORT="$STICK_PORT" \
42
+ node tools/extract-frame.mjs "$PCAP" "$FRAME"
43
+
44
+ echo
45
+ echo ">>> Running try-hardcoded-key.mjs against the captured frame"
46
+ node tools/try-hardcoded-key.mjs "$FRAME"
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # Capture a COMPLETE HWM<->Stick session: TCP/2431 (control + handshake) +
3
+ # UDP/2431 (encrypted DMX stream). We need the handshake from the very first
4
+ # byte, so HWM should be DISCONNECTED from the Stick before running this.
5
+ #
6
+ # Workflow:
7
+ # 1. In HWM: disconnect from the Stick (Network panel -> Disconnect).
8
+ # 2. Run this script. It captures for 20 sec.
9
+ # 3. While it's capturing: in HWM click Connect.
10
+ # 4. Wait for HWM to reach "Connected" state, then move a fader so DMX
11
+ # starts streaming. Script should record both the TCP handshake AND
12
+ # a few UDP DMX frames.
13
+ #
14
+ # Output: /tmp/stick-handshake.pcap (timestamped copy at ./tools/captures/<ts>.pcap)
15
+
16
+ set -euo pipefail
17
+ cd "$(dirname "$0")/.."
18
+
19
+ IFACE="${IFACE:-en0}"
20
+ STICK_IP="${STICK_IP:-192.168.96.2}"
21
+ DUR="${DUR:-60}"
22
+ PCAP=/tmp/stick-handshake.pcap
23
+
24
+ mkdir -p tools/captures
25
+ TS=$(date +%Y%m%d-%H%M%S)
26
+ SAVE="tools/captures/handshake-${TS}.pcap"
27
+
28
+ echo ">>> About to capture ${DUR}s of ALL traffic to/from ${STICK_IP} on ${IFACE}"
29
+ echo ">>> "
30
+ echo ">>> BEFORE this starts: disconnect HWM from the Stick."
31
+ echo ">>> AS SOON as the capture starts: click HWM Connect, then once"
32
+ echo ">>> connected, move a fader to start DMX streaming."
33
+ echo ">>> "
34
+ read -p "Ready? (y/Enter to start, anything else to abort): " ans
35
+ [[ -z "$ans" || "$ans" == "y" || "$ans" == "Y" ]] || { echo "aborted"; exit 1; }
36
+
37
+ echo ">>> Capturing..."
38
+ sudo tcpdump -i "$IFACE" -w "$PCAP" -G "$DUR" -W 1 \
39
+ "host ${STICK_IP}" \
40
+ 2>/dev/null || true
41
+
42
+ echo
43
+ echo ">>> pcap size: $(wc -c < "$PCAP") bytes"
44
+ cp "$PCAP" "$SAVE"
45
+ echo ">>> archived to $SAVE"
46
+
47
+ echo
48
+ echo ">>> Packet summary (TCP control + UDP DMX)"
49
+ node tools/dump-pcap-summary.mjs "$PCAP" > /tmp/stick-handshake-summary.txt
50
+ echo " written to /tmp/stick-handshake-summary.txt ($(wc -l < /tmp/stick-handshake-summary.txt) packets)"
51
+ echo
52
+ echo ">>> First few TCP frames (the handshake):"
53
+ grep ' TCP ' /tmp/stick-handshake-summary.txt | head -10
54
+ echo
55
+ echo ">>> First UDP DMX frame's first 32B (clear header):"
56
+ grep ' UDP .* 576B' /tmp/stick-handshake-summary.txt | head -1
57
+ echo
58
+ echo ">>> Full summary at /tmp/stick-handshake-summary.txt"
59
+ echo ">>> pcap at $SAVE — share this output and we'll decode the handshake"
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # Capture a send_dmx run: all TCP + UDP traffic to/from the Stick while
3
+ # send_dmx connects, handshakes and streams. The resulting pcap shows whether
4
+ # the Stick accepts our handshake, whether our UDP DMX frames go out, and
5
+ # whether the Stick errors or drops the connection.
6
+ #
7
+ # ./tools/capture-send.sh <ip> <universe,channel=value> [...]
8
+ # e.g. ./tools/capture-send.sh 192.168.96.2 0,6=255 0,9=255 0,10=170
9
+ #
10
+ # Needs sudo for tcpdump (it will prompt). Output: /tmp/send_dmx-run.pcap
11
+
12
+ set -euo pipefail
13
+ cd "$(dirname "$0")/.."
14
+
15
+ IFACE="${IFACE:-en0}"
16
+ IP="${1:?usage: capture-send.sh <ip> <universe,channel=value>...}"
17
+ shift
18
+ PCAP=/tmp/send_dmx-run.pcap
19
+ rm -f "$PCAP"
20
+
21
+ echo ">>> starting tcpdump (sudo) on ${IFACE}, host ${IP}"
22
+ sudo tcpdump -i "$IFACE" -w "$PCAP" "host ${IP}" >/dev/null 2>&1 &
23
+ sleep 3 # let tcpdump come up
24
+
25
+ echo ">>> running: send_dmx ${IP} $*"
26
+ node tools/send_dmx.mjs "$IP" "$@" || true
27
+ sleep 2 # let the last frames + any Stick response land
28
+
29
+ echo ">>> stopping tcpdump"
30
+ sudo pkill -INT -f 'tcpdump.*send_dmx-run' 2>/dev/null || true
31
+ sleep 1
32
+
33
+ echo
34
+ echo ">>> pcap: ${PCAP} ($(wc -c < "$PCAP" 2>/dev/null || echo 0) bytes)"
35
+ if [[ -s "$PCAP" ]]; then
36
+ node tools/dump-pcap-summary.mjs "$PCAP" 2>/dev/null | head -50 || true
37
+ echo ">>> total packets: $(node tools/dump-pcap-summary.mjs "$PCAP" 2>/dev/null | wc -l)"
38
+ fi
39
+ echo ">>> done — tell the assistant; ${PCAP} will be analysed."
@@ -0,0 +1,121 @@
1
+ // Crack the key-derivation function: secret -> 32-byte AES-256 key.
2
+ //
3
+ // The AES key is KDF(ECDH shared secret). The cipher object stores a 32-byte
4
+ // block A||B at cipher_obj+0x28, right before the key at +0x48 — likely the
5
+ // shared secret kept resident. This slides a 32-byte "secret" window over all
6
+ // captured memory blobs and tries a battery of KDFs against the known key.
7
+ // If any KDF(window) == key, the derivation (and the secret's location) is
8
+ // found — and the plugin can replicate it with no further RE.
9
+ //
10
+ // Usage:
11
+ // node tools/crack-kdf.mjs [key-hex] [blob.bin ...]
12
+ // key defaults to the Path C recovered key
13
+ // blobs default to /tmp/stick-pathC-blob-*.bin
14
+
15
+ import * as crypto from 'node:crypto';
16
+ import * as fs from 'node:fs';
17
+
18
+ const DEFAULT_KEY =
19
+ '531e1b9f097be5de5785f5bf86473da34839fabb76676e95c0e4b808b42f8ac0';
20
+ // hardcoded 33-byte secret deobfuscated from the binary (memory note)
21
+ const HC = Buffer.from(
22
+ '527a5b46c56f3a5e670b4f0e338727d9' +
23
+ '4737ec0fc4af0dba93a51d93965191d0' + '8f', 'hex');
24
+
25
+ let [, , maybeKey, ...blobArgs] = process.argv;
26
+ let keyHex = DEFAULT_KEY;
27
+ if (maybeKey && /^[0-9a-fA-F]{64}$/.test(maybeKey)) keyHex = maybeKey;
28
+ else if (maybeKey) blobArgs.unshift(maybeKey);
29
+ const KEY = Buffer.from(keyHex, 'hex');
30
+
31
+ let blobs = blobArgs;
32
+ if (blobs.length === 0) {
33
+ blobs = fs.readdirSync('/tmp')
34
+ .filter(f => /^stick-pathC-blob-.*\.bin$/.test(f))
35
+ .map(f => '/tmp/' + f);
36
+ }
37
+ if (blobs.length === 0) { console.error('no blobs found'); process.exit(1); }
38
+
39
+ const sha256 = b => crypto.createHash('sha256').update(b).digest();
40
+ const sha512 = b => crypto.createHash('sha512').update(b).digest();
41
+ const sha1 = b => crypto.createHash('sha1').update(b).digest();
42
+ const md5 = b => crypto.createHash('md5').update(b).digest();
43
+ const blake2b = b => crypto.createHash('blake2b512').update(b).digest();
44
+ const blake2s = b => crypto.createHash('blake2s256').update(b).digest();
45
+ const hmac = (k, m) => crypto.createHmac('sha256', k).update(m).digest();
46
+ const cat = (...xs) => Buffer.concat(xs);
47
+ const rev = b => Buffer.from(b).reverse();
48
+ const xor = (a, b) => { const o = Buffer.alloc(32); for (let i = 0; i < 32; i++) o[i] = a[i % a.length] ^ b[i % b.length]; return o; };
49
+ function ecb(keyB, data) {
50
+ const c = crypto.createCipheriv('aes-256-ecb', keyB, null);
51
+ c.setAutoPadding(false);
52
+ return Buffer.concat([c.update(data), c.final()]);
53
+ }
54
+
55
+ const HC32 = HC.subarray(0, 32);
56
+ const HC16a = HC.subarray(0, 16);
57
+ const HC16b = HC.subarray(16, 32);
58
+
59
+ // each KDF maps a candidate secret W (32 bytes) -> a 32-byte key
60
+ const KDFS = [
61
+ ['identity', W => W],
62
+ ['sha256(W)', W => sha256(W)],
63
+ ['sha256(sha256(W))', W => sha256(sha256(W))],
64
+ ['sha512(W)[:32]', W => sha512(W).subarray(0, 32)],
65
+ ['blake2b(W)[:32]', W => blake2b(W).subarray(0, 32)],
66
+ ['blake2s(W)', W => blake2s(W)],
67
+ ['md5(W)|md5(revW)', W => cat(md5(W), md5(rev(W)))],
68
+ ['sha256(W|HC33)', W => sha256(cat(W, HC))],
69
+ ['sha256(HC33|W)', W => sha256(cat(HC, W))],
70
+ ['sha256(W|HC32)', W => sha256(cat(W, HC32))],
71
+ ['sha256(HC32|W)', W => sha256(cat(HC32, W))],
72
+ ['sha512(W|HC33)[:32]', W => sha512(cat(W, HC)).subarray(0, 32)],
73
+ ['sha512(HC33|W)[:32]', W => sha512(cat(HC, W)).subarray(0, 32)],
74
+ ['hmac(HC33,W)', W => hmac(HC, W)],
75
+ ['hmac(W,HC33)', W => hmac(W, HC)],
76
+ ['hmac(HC32,W)', W => hmac(HC32, W)],
77
+ ['hmac(W,HC32)', W => hmac(W, HC32)],
78
+ ['hmac(W,W)', W => hmac(W, W)],
79
+ ['W xor HC32', W => xor(W, HC32)],
80
+ ['sha256(W xor HC32)', W => sha256(xor(W, HC32))],
81
+ ['sha256(W|W)', W => sha256(cat(W, W))],
82
+ ['aes256ecb(HC32,W)', W => ecb(HC32, W)],
83
+ ['aes256ecb(W,HC32)', W => ecb(W, HC32)],
84
+ ['sha256(W|HC16a)', W => sha256(cat(W, HC16a))],
85
+ ['sha256(W|HC16b)', W => sha256(cat(W, HC16b))],
86
+ ['sha256(sha1(W)|sha1(HC32))', W => sha256(cat(sha1(W), sha1(HC32)))],
87
+ ];
88
+
89
+ const want = KEY.toString('hex');
90
+ let tried = 0, hit = false;
91
+
92
+ for (const path of blobs) {
93
+ let buf;
94
+ try { buf = fs.readFileSync(path); } catch { continue; }
95
+ const name = path.split('/').pop();
96
+ for (let o = 0; o + 32 <= buf.length; o++) {
97
+ const W = buf.subarray(o, o + 32);
98
+ for (const [kname, fn] of KDFS) {
99
+ tried++;
100
+ let out;
101
+ try { out = fn(W); } catch { continue; }
102
+ if (out && out.length >= 32 && out.subarray(0, 32).toString('hex') === want) {
103
+ hit = true;
104
+ console.log(`\n*** KDF MATCH ***`);
105
+ console.log(` key = ${want}`);
106
+ console.log(` KDF = ${kname}`);
107
+ console.log(` secret W = ${W.toString('hex')}`);
108
+ console.log(` location = ${name} + 0x${o.toString(16)}`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ console.log(`\ntried ${tried} (window, KDF) combinations across ${blobs.length} blob(s)`);
115
+ if (!hit) {
116
+ console.log('no KDF in the battery maps any 32-byte window to the key.');
117
+ console.log('Next: capture a handshake + memory dump in one session, recover');
118
+ console.log('client_priv, compute the real X25519 shared secret, and either');
119
+ console.log('extend this battery or trace the KDF function directly.');
120
+ process.exit(3);
121
+ }
@@ -0,0 +1,170 @@
1
+ // Path A, final step — brute-force the AES key out of a saved cipher-object
2
+ // dump by testing every window against a captured 576-byte DMX frame.
3
+ //
4
+ // The lldb scan (lldb_pathA_scan.py) saved the cipher object to
5
+ // /tmp/stick-pathA-obj-*.bin. The raw key is somewhere in there, but the
6
+ // exact offset / length / mode / byte order are uncertain. So: slide a
7
+ // window over the whole dump, try it as an AES key in every common mode,
8
+ // and score the decrypted DMX payload (DMX = mostly-zero bytes).
9
+ //
10
+ // Modes and what the IV does:
11
+ // ecb - no IV; every block independent
12
+ // cbc/cfb/cfb8 - IV only affects the first block(s); blocks 1..33 (528
13
+ // bytes) decrypt from the key alone -> scored IV-free
14
+ // ofb/ctr - keystream depends on key AND the nonce/counter, every
15
+ // block -> we try several nonce-derived 16-byte IVs
16
+ //
17
+ // Usage:
18
+ // node tools/crack-key.mjs <frame.bin> [objdump.bin ...]
19
+ // (objdumps default to /tmp/stick-pathA-obj-*.bin)
20
+
21
+ import * as crypto from 'node:crypto';
22
+ import * as fs from 'node:fs';
23
+
24
+ let [, , framePath, ...objPaths] = process.argv;
25
+ if (!framePath) {
26
+ console.error('usage: node tools/crack-key.mjs <frame.bin> [objdump.bin ...]');
27
+ process.exit(1);
28
+ }
29
+ if (objPaths.length === 0) {
30
+ objPaths = fs.readdirSync('/tmp')
31
+ .filter(f => /^stick-pathA-obj-.*\.bin$/.test(f))
32
+ .map(f => '/tmp/' + f);
33
+ if (objPaths.length === 0) {
34
+ console.error('no /tmp/stick-pathA-obj-*.bin found — pass dumps explicitly');
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ const frame = fs.readFileSync(framePath);
40
+ if (frame.length !== 576) {
41
+ console.error(`frame must be 576 bytes, got ${frame.length}`);
42
+ process.exit(1);
43
+ }
44
+ const header = frame.subarray(0, 0x20);
45
+ const body = frame.subarray(0x20, 0x240); // 544 bytes, 34 AES blocks
46
+ const nonce = Buffer.from(header.subarray(0x18, 0x20)); // 8 bytes
47
+
48
+ // candidate 16-byte IV / initial-counter blocks built from the clear header
49
+ const z8 = Buffer.alloc(8);
50
+ const ivCandidates = [
51
+ ['nonce|0', Buffer.concat([nonce, z8])],
52
+ ['0|nonce', Buffer.concat([z8, nonce])],
53
+ ['nonce|nonce', Buffer.concat([nonce, nonce])],
54
+ ['nonce|0..1', Buffer.concat([nonce, Buffer.from('0000000000000001', 'hex')])],
55
+ ['hdr[0:16]', Buffer.from(header.subarray(0, 16))],
56
+ ['hdr[16:32]', Buffer.from(header.subarray(16, 32))],
57
+ ['zero', Buffer.alloc(16)],
58
+ ];
59
+ const ZERO_IV = Buffer.alloc(16);
60
+
61
+ // score a region of the plaintext: fraction of zero bytes
62
+ function zeroRatio(out, from) {
63
+ let zeros = 0;
64
+ for (let i = from; i < out.length; i++) if (out[i] === 0) zeros++;
65
+ return zeros / (out.length - from);
66
+ }
67
+
68
+ function wordswap(buf) {
69
+ const o = Buffer.from(buf);
70
+ for (let i = 0; i + 4 <= o.length; i += 4) {
71
+ const a = o[i], b = o[i + 1], c = o[i + 2], d = o[i + 3];
72
+ o[i] = d; o[i + 1] = c; o[i + 2] = b; o[i + 3] = a;
73
+ }
74
+ return o;
75
+ }
76
+
77
+ function tryDecrypt(algo, key, iv) {
78
+ const d = iv === null
79
+ ? crypto.createDecipheriv(algo, key, null)
80
+ : crypto.createDecipheriv(algo, key, iv);
81
+ d.setAutoPadding(false);
82
+ return Buffer.concat([d.update(body), d.final()]);
83
+ }
84
+
85
+ const results = [];
86
+ let tried = 0;
87
+
88
+ for (const objPath of objPaths) {
89
+ const buf = fs.readFileSync(objPath);
90
+ const objName = objPath.split('/').pop();
91
+ for (const keylen of [16, 24, 32]) {
92
+ const bits = keylen * 8;
93
+ for (let off = 0; off + keylen <= buf.length; off++) {
94
+ const window = buf.subarray(off, off + keylen);
95
+ for (const [formName, keyBuf] of [['asis', window], ['wswap', wordswap(window)]]) {
96
+ const note = (extra) => ({ obj: objName, off, keylen, form: formName,
97
+ key: Buffer.from(keyBuf).toString('hex'), ...extra });
98
+
99
+ // --- ECB: no IV, score everything --------------------------------
100
+ try { tried++;
101
+ const out = tryDecrypt(`aes-${bits}-ecb`, keyBuf, null);
102
+ const z = zeroRatio(out, 0);
103
+ if (z > 0.55) results.push(note({ mode: 'ecb', iv: '-', z,
104
+ sample: out.subarray(0, 32).toString('hex') }));
105
+ } catch { /* skip */ }
106
+
107
+ // --- CBC / CFB / CFB8: IV-free score of blocks 1.. --------------
108
+ for (const mode of ['cbc', 'cfb', 'cfb8']) {
109
+ try { tried++;
110
+ const out = tryDecrypt(`aes-${bits}-${mode}`, keyBuf, ZERO_IV);
111
+ const z = zeroRatio(out, 16);
112
+ if (z > 0.55) results.push(note({ mode, iv: 'n/a(blk1+)', z,
113
+ sample: out.subarray(16, 48).toString('hex') }));
114
+ } catch { /* skip */ }
115
+ }
116
+
117
+ // --- OFB / CTR: keystream depends on the nonce ------------------
118
+ for (const mode of ['ofb', 'ctr']) {
119
+ for (const [ivName, iv] of ivCandidates) {
120
+ try { tried++;
121
+ const out = tryDecrypt(`aes-${bits}-${mode}`, keyBuf, iv);
122
+ const z = zeroRatio(out, 0);
123
+ if (z > 0.55) results.push(note({ mode, iv: ivName, z,
124
+ sample: out.subarray(0, 32).toString('hex') }));
125
+ } catch { /* skip */ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ console.log(`tried ${tried} (key,mode,iv,order) combinations`);
134
+ console.log(`frame: seq=${frame[0x17]} nonce=${nonce.toString('hex')}`);
135
+ console.log();
136
+
137
+ results.sort((a, b) => b.z - a.z);
138
+ if (results.length === 0) {
139
+ console.log('❌ nothing decrypted to DMX-shaped plaintext under any of:');
140
+ console.log(' ecb / cbc / cfb / cfb8 / ofb / ctr (aes-128/192/256, both byte orders).');
141
+ console.log(' The key is not a byte-contiguous window of the cipher object,');
142
+ console.log(' OR it is derived (KDF) from the object bytes before use,');
143
+ console.log(' OR the block cipher is non-standard. Next: capture a 2nd frame');
144
+ console.log(' for a keystream diff, or Path C (patch the encrypt fn to log $rdi).');
145
+ process.exit(3);
146
+ }
147
+
148
+ console.log(`top ${Math.min(15, results.length)} candidates (zero-ratio):`);
149
+ for (const r of results.slice(0, 15)) {
150
+ const pct = (r.z * 100).toFixed(1);
151
+ const flag = r.z > 0.80 ? ' <<< KEY FOUND' : '';
152
+ console.log(` ${pct}% aes-${r.keylen * 8}-${r.mode} iv=${r.iv} ${r.form}` +
153
+ ` ${r.obj}+0x${r.off.toString(16)}${flag}`);
154
+ console.log(` key=${r.key}`);
155
+ console.log(` plaintext=${r.sample}`);
156
+ }
157
+
158
+ const best = results[0];
159
+ console.log();
160
+ if (best.z > 0.80) {
161
+ console.log('✅ KEY RECOVERED');
162
+ console.log(` algorithm : aes-${best.keylen * 8}-${best.mode}` +
163
+ ` iv=${best.iv} (byte order: ${best.form})`);
164
+ console.log(` key : ${best.key}`);
165
+ console.log(` location : ${best.obj} +0x${best.off.toString(16)}`);
166
+ process.exit(0);
167
+ } else {
168
+ console.log('⚠️ best candidate is below the 80% confidence bar — inspect above.');
169
+ process.exit(2);
170
+ }