@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,103 @@
1
+ // Try decrypting a captured 576-byte DMX frame using the hardcoded 33-byte
2
+ // secret discovered by Ghidra static RE.
3
+ //
4
+ // Background:
5
+ // Ghidra found a deobfuscator (cipher vtable's vt[7], at FUN_100180E90 +
6
+ // FUN_100180D40 — byte-identical compiler dupes). It reads 33 obfuscated
7
+ // bytes at 0x1007B0490 and XORs them with the mask 0x51..0x71 to produce a
8
+ // 33-byte hardcoded secret. We decoded that secret offline:
9
+ //
10
+ // hex: 527a5b46c56f3a5e670b4f0e338727d9
11
+ // 4737ec0fc4af0dba93a51d93965191d0
12
+ // 8f
13
+ //
14
+ // It MIGHT be: 16-byte AES-128 key + 16-byte IV + 1-byte flag (33 total),
15
+ // used directly by the cipher without any per-session handshake.
16
+ //
17
+ // To verify, capture a 576-byte UDP frame from the Stick (UDP→192.168.96.2:2431,
18
+ // live DMX from Hardware Manager) and try to decrypt its 544-byte body
19
+ // (offset +0x20..+0x240) using AES-128-CBC and AES-128-CFB with these key/IV.
20
+ // If either yields plaintext that looks like DMX (mostly 0s with a few
21
+ // non-zero channel values), the hardcoded secret IS the key.
22
+
23
+ import * as crypto from 'node:crypto';
24
+ import * as fs from 'node:fs';
25
+
26
+ const KEY = Buffer.from('527a5b46c56f3a5e670b4f0e338727d9', 'hex');
27
+ const IV = Buffer.from('4737ec0fc4af0dba93a51d93965191d0', 'hex');
28
+ // 33rd byte (0x8f) — meaning TBD; might be a mode flag or version byte.
29
+
30
+ if (process.argv.length < 3) {
31
+ console.error('usage: tsx tools/try-hardcoded-key.ts <captured-576B-frame.bin>');
32
+ console.error(' (capture with: tcpdump -i en0 -w stick.pcap udp port 2431,');
33
+ console.error(' then extract the first 576-byte UDP payload as a raw .bin file)');
34
+ process.exit(1);
35
+ }
36
+
37
+ const frame = fs.readFileSync(process.argv[2]);
38
+ if (frame.length !== 576) {
39
+ console.error(`expected exactly 576 bytes, got ${frame.length}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const header = frame.subarray(0, 0x20); // 32 bytes clear
44
+ const body = frame.subarray(0x20, 0x240); // 544 bytes encrypted
45
+
46
+ console.log('clear header (32B):');
47
+ console.log(' ', header.toString('hex').match(/../g)!.join(' '));
48
+ console.log(` session_magic[0..8] = ${header.subarray(0, 8).toString('hex')}`);
49
+ console.log(` opcode/version[8..a] = 0x${header.readUInt16LE(8).toString(16)}`);
50
+ console.log(` field[a..12] = ${header.subarray(0x0a, 0x12).toString('hex')}`);
51
+ console.log(` field[12..14] = 0x${header.readUInt16LE(0x12).toString(16)}`);
52
+ console.log(` channel count[14..16] = ${header.readUInt16LE(0x14)}`);
53
+ console.log(` const100[16] = 0x${header[0x16].toString(16)}`);
54
+ console.log(` seq_counter[17] = ${header[0x17]}`);
55
+ console.log(` nonce[18..20] = ${header.subarray(0x18, 0x20).toString('hex')}`);
56
+ console.log();
57
+ console.log('encrypted body length:', body.length, 'bytes (= 34 × 16B blocks)');
58
+ console.log();
59
+
60
+ function score(buf: Buffer): { zeroRatio: number; sample: string } {
61
+ let zeros = 0;
62
+ for (const b of buf) if (b === 0) zeros++;
63
+ return {
64
+ zeroRatio: zeros / buf.length,
65
+ sample: buf.subarray(0, 32).toString('hex'),
66
+ };
67
+ }
68
+
69
+ function tryDecrypt(label: string, algo: string, key: Buffer, iv: Buffer): void {
70
+ try {
71
+ const d = crypto.createDecipheriv(algo, key, iv);
72
+ d.setAutoPadding(false);
73
+ const out = Buffer.concat([d.update(body), d.final()]);
74
+ const s = score(out);
75
+ console.log(`${label}:`);
76
+ console.log(` zero ratio: ${(s.zeroRatio * 100).toFixed(1)}% (DMX with mostly off lights should be high)`);
77
+ console.log(` first 32B: ${s.sample}`);
78
+ } catch (e: any) {
79
+ console.log(`${label}: error: ${e.message}`);
80
+ }
81
+ }
82
+
83
+ console.log('=== Trying hardcoded key, hardcoded IV ===');
84
+ tryDecrypt('AES-128-CBC', 'aes-128-cbc', KEY, IV);
85
+ tryDecrypt('AES-128-CFB', 'aes-128-cfb', KEY, IV);
86
+
87
+ console.log();
88
+ console.log('=== Trying hardcoded key, nonce-as-IV (frame[+0x18..+0x20] zero-padded to 16B) ===');
89
+ const nonceIv = Buffer.concat([header.subarray(0x18, 0x20), Buffer.alloc(8)]);
90
+ tryDecrypt('AES-128-CBC', 'aes-128-cbc', KEY, nonceIv);
91
+ tryDecrypt('AES-128-CFB', 'aes-128-cfb', KEY, nonceIv);
92
+
93
+ console.log();
94
+ console.log('=== Trying hardcoded key, full clear-header[16..32] as IV ===');
95
+ const headerTailIv = header.subarray(0x10, 0x20);
96
+ tryDecrypt('AES-128-CBC', 'aes-128-cbc', KEY, headerTailIv);
97
+ tryDecrypt('AES-128-CFB', 'aes-128-cfb', KEY, headerTailIv);
98
+
99
+ console.log();
100
+ console.log('If any variant above shows >85% zeros + sensible DMX channel values,');
101
+ console.log('the hardcoded secret IS the key/IV — the plugin can encrypt without');
102
+ console.log('any per-session handshake. If none works, the handshake derives the');
103
+ console.log('key (see cipher vt[4]/[5]/[6] = opcodes 0xF/0x10/0x11).');
@@ -0,0 +1,103 @@
1
+ // Verify a recovered AES key against a captured 576-byte DMX frame.
2
+ //
3
+ // Generalises try-hardcoded-key.mjs: takes the key (and optionally IV / mode)
4
+ // on the command line, so it works with keys extracted by the Path A lldb
5
+ // scan (tools/lldb-pathA-scan.py).
6
+ //
7
+ // Usage:
8
+ // node tools/try-key.mjs <frame.bin> <key-hex> [iv-hex] [cbc|cfb]
9
+ //
10
+ // Key insight: with the CORRECT key, AES-CBC and AES-CFB both decrypt blocks
11
+ // 1..33 correctly regardless of the IV (only block 0 depends on the IV). So
12
+ // a high zero-ratio here confirms the key even if the IV is unknown/wrong.
13
+ //
14
+ // Examples:
15
+ // node tools/try-key.mjs /tmp/frame.bin $(xxd -p -c256 /tmp/stick-pathA-key-0.bin)
16
+ // node tools/try-key.mjs /tmp/frame.bin 527a5b46... 0011.. cfb
17
+
18
+ import * as crypto from 'node:crypto';
19
+ import * as fs from 'node:fs';
20
+
21
+ const [, , framePath, keyHex, ivHex, modeArg] = process.argv;
22
+ if (!framePath || !keyHex) {
23
+ console.error('usage: node tools/try-key.mjs <frame.bin> <key-hex> [iv-hex] [cbc|cfb]');
24
+ process.exit(1);
25
+ }
26
+
27
+ const frame = fs.readFileSync(framePath);
28
+ if (frame.length !== 576) {
29
+ console.error(`expected exactly 576 bytes, got ${frame.length}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ const key = Buffer.from(keyHex.replace(/\s/g, ''), 'hex');
34
+ if (key.length !== 16) {
35
+ console.error(`key must be 16 bytes (32 hex chars), got ${key.length}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ const header = frame.subarray(0, 0x20);
40
+ const body = frame.subarray(0x20, 0x240); // 544 bytes = 34 AES blocks
41
+
42
+ // candidate IVs to try (block 0 only depends on this; blocks 1..33 don't)
43
+ const ivs = [];
44
+ if (ivHex) {
45
+ ivs.push(['cli', Buffer.from(ivHex.replace(/\s/g, ''), 'hex')]);
46
+ } else {
47
+ ivs.push(['zero', Buffer.alloc(16)]);
48
+ ivs.push(['nonce|0', Buffer.concat([header.subarray(0x18, 0x20), Buffer.alloc(8)])]);
49
+ ivs.push(['hdr16..32', Buffer.from(header.subarray(0x10, 0x20))]);
50
+ }
51
+
52
+ const modes = modeArg ? [modeArg.toLowerCase()] : ['cbc', 'cfb'];
53
+
54
+ console.log('clear header (32B):', header.toString('hex').match(/../g).join(' '));
55
+ console.log(` session_magic = ${header.subarray(0, 8).toString()}`);
56
+ console.log(` channels = ${header.readUInt16LE(0x14)}`);
57
+ console.log(` seq = ${header[0x17]}`);
58
+ console.log(` nonce = ${header.subarray(0x18, 0x20).toString('hex')}`);
59
+ console.log(` key = ${key.toString('hex')}`);
60
+ console.log();
61
+
62
+ function analyse(out) {
63
+ let zeros = 0, max = 0, nonzero = [];
64
+ for (let i = 0; i < out.length; i++) {
65
+ if (out[i] === 0) zeros++;
66
+ else { if (out[i] > max) max = out[i]; if (nonzero.length < 24) nonzero.push(`${i}:${out[i]}`); }
67
+ }
68
+ return { zeroRatio: zeros / out.length, max, nonzero };
69
+ }
70
+
71
+ let best = null;
72
+ for (const algo of modes) {
73
+ const cipherName = algo === 'cbc' ? 'aes-128-cbc' : 'aes-128-cfb';
74
+ for (const [ivLabel, iv] of ivs) {
75
+ if (iv.length !== 16) { console.log(` skip ${algo}/${ivLabel}: bad iv len`); continue; }
76
+ try {
77
+ const d = crypto.createDecipheriv(cipherName, key, iv);
78
+ d.setAutoPadding(false);
79
+ const out = Buffer.concat([d.update(body), d.final()]);
80
+ const a = analyse(out);
81
+ const pct = (a.zeroRatio * 100).toFixed(1);
82
+ const verdict = a.zeroRatio > 0.80 ? ' <<< LOOKS LIKE VALID DMX' : '';
83
+ console.log(`${algo.toUpperCase()} iv=${ivLabel.padEnd(10)} zero=${pct}% max=${a.max}${verdict}`);
84
+ console.log(` first 32B: ${out.subarray(0, 32).toString('hex')}`);
85
+ if (a.nonzero.length) console.log(` nonzero[idx:val]: ${a.nonzero.join(' ')}`);
86
+ if (!best || a.zeroRatio > best.zeroRatio) best = { algo, ivLabel, ...a };
87
+ } catch (e) {
88
+ console.log(`${algo.toUpperCase()} iv=${ivLabel}: error ${e.message}`);
89
+ }
90
+ }
91
+ }
92
+
93
+ console.log();
94
+ if (best && best.zeroRatio > 0.80) {
95
+ console.log(`✅ KEY VERIFIED — best: ${best.algo.toUpperCase()} iv=${best.ivLabel} ` +
96
+ `zero=${(best.zeroRatio * 100).toFixed(1)}%`);
97
+ console.log(' (block 0 may differ until the real IV is known — that is expected)');
98
+ process.exit(0);
99
+ } else {
100
+ console.log('❌ key did not produce DMX-shaped plaintext.');
101
+ console.log(' Either the key is wrong, or the cipher/mode differs from AES-128.');
102
+ process.exit(3);
103
+ }
@@ -0,0 +1,121 @@
1
+ // Verify the recovered AES-256 key by decrypting every 0x011c reply across
2
+ // multiple pcaps + locate the key's address in the HardwareManager binary.
3
+ //
4
+ // node tools/verify-011c-key.mjs
5
+
6
+ import fs from 'node:fs';
7
+ import crypto from 'node:crypto';
8
+
9
+ const KEY = Buffer.from('ea079afbbdd859719322e3f6e3c670223da627f9b6146e4ba6eb0f4a18739205', 'hex');
10
+ const HWM = '/Applications/ESA2/HardwareManager/HardwareManager.app/Contents/MacOS/HardwareManager';
11
+ const PCAPS = ['/tmp/hwm-A.pcap', '/tmp/hwm-B.pcap', '/tmp/sd-xhl.pcap'];
12
+
13
+ function ipFromEth(p){if(p.length<14)return null;let o=12,e=p.readUInt16BE(o);for(let i=0;i<2&&e===0x8100;i++){o+=4;if(o+2>p.length)return null;e=p.readUInt16BE(o);}return e===0x0800?p.subarray(o+2):null;}
14
+
15
+ function extractReplies(pcap) {
16
+ const buf = fs.readFileSync(pcap);
17
+ const le = buf.readUInt32LE(0) === 0xa1b2c3d4;
18
+ const streams = new Map();
19
+ let off = 24;
20
+ while (off + 16 <= buf.length) {
21
+ const capLen = le ? buf.readUInt32LE(off+8) : buf.readUInt32BE(off+8);
22
+ off += 16; if (off + capLen > buf.length) break;
23
+ const pkt = buf.subarray(off, off+capLen); off += capLen;
24
+ const ip = ipFromEth(pkt); if (!ip || ip.length<20 || ((ip[0]>>4)&0xf)!==4) continue;
25
+ const ihl = (ip[0]&0xf)*4; if (ip[9]!==6) continue;
26
+ const tcp = ip.subarray(ihl);
27
+ if (tcp.readUInt16BE(0) !== 2431) continue;
28
+ const seq = tcp.readUInt32BE(4); const dataOff = (tcp[12]>>4)*4;
29
+ const payload = tcp.subarray(dataOff); if (!payload.length) continue;
30
+ const k = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}<->${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}`;
31
+ if (!streams.has(k)) streams.set(k, []);
32
+ streams.get(k).push({ seq, payload });
33
+ }
34
+ const out = [];
35
+ for (const [k, segs] of streams) {
36
+ segs.sort((a,b)=>(a.seq-b.seq+0x100000000)%0x100000000);
37
+ const seen = new Set(); const arr = [];
38
+ const base = segs[0].seq;
39
+ for (const s of segs) {
40
+ const kk = `${s.seq}:${s.payload.length}`; if (seen.has(kk)) continue; seen.add(kk);
41
+ const o = (s.seq - base + 0x100000000) % 0x100000000;
42
+ while (arr.length < o + s.payload.length) arr.push(0);
43
+ for (let i = 0; i < s.payload.length; i++) arr[o+i] = s.payload[i];
44
+ }
45
+ const r = Buffer.from(arr);
46
+ for (let i = 0; i + 24 <= r.length; i++) {
47
+ if (r.subarray(i,i+8).toString() !== 'Stick_3A') continue;
48
+ if (r.readUInt16LE(i+8) !== 0x011c) continue;
49
+ if (r.readUInt32LE(i+0x14) !== 284) continue;
50
+ const msg = r.subarray(i, i+284);
51
+ out.push({ pcap, stream: k, token: msg.readUInt32LE(0x0a),
52
+ iv: msg.subarray(0x1a, 0x2a),
53
+ ct: msg.subarray(0x2c, 0x2c+240) });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function dec(iv, ct) {
60
+ const d = crypto.createDecipheriv('aes-256-cbc', KEY, iv);
61
+ d.setAutoPadding(false);
62
+ return Buffer.concat([d.update(ct), d.final()]);
63
+ }
64
+
65
+ console.log('=== verify key against captured replies ===\n');
66
+ let constMap = null;
67
+ const allPlaintexts = [];
68
+ for (const p of PCAPS) {
69
+ for (const r of extractReplies(p)) {
70
+ const pt = dec(r.iv, r.ct);
71
+ allPlaintexts.push(pt);
72
+ console.log(`${p} stream ${r.stream} token=${r.token}`);
73
+ console.log(` pt[ 0:32 ] = ${pt.subarray(0,32).toString('hex')}`);
74
+ console.log(` pt[ 32:64 ] = ${pt.subarray(32,64).toString('hex')}`);
75
+ console.log(` pt[ 64:96 ] = ${pt.subarray(64,96).toString('hex')}`);
76
+ console.log(` pt[ 96:128] = ${pt.subarray(96,128).toString('hex')}`);
77
+ console.log(` pt[128:160] = ${pt.subarray(128,160).toString('hex')}`);
78
+ console.log(` pt[160:192] = ${pt.subarray(160,192).toString('hex')}`);
79
+ console.log(` pt[192:224] = ${pt.subarray(192,224).toString('hex')}`);
80
+ console.log(` pt[224:240] = ${pt.subarray(224,240).toString('hex')}`);
81
+ console.log(` ascii : ${pt.toString('binary').replace(/[^\x20-\x7e]/g,'.').slice(0,160)}`);
82
+ // self-consistency check
83
+ let comp = 0;
84
+ for (let i = 0; i < 4; i++) if (((pt[i] ^ pt[0x64+i]) & 0xff) === 0xff) comp++;
85
+ console.log(` complement-check pt[0..4] vs ~pt[0x64..0x68] : ${comp}/4 bytes`);
86
+ console.log();
87
+ }
88
+ }
89
+
90
+ // Constant-byte map across all plaintexts
91
+ if (allPlaintexts.length >= 2) {
92
+ console.log('=== byte-by-byte CONST/VAR map across all decrypted replies ===');
93
+ const ranges = []; let cur = null;
94
+ for (let i = 0; i < 240; i++) {
95
+ const vals = new Set(allPlaintexts.map(p => p[i]));
96
+ const kind = vals.size === 1 ? 'const' : 'var';
97
+ if (!cur || cur.kind !== kind) { if (cur) ranges.push(cur); cur = { start: i, end: i, kind }; }
98
+ else cur.end = i;
99
+ }
100
+ if (cur) ranges.push(cur);
101
+ for (const r of ranges) {
102
+ const slice = allPlaintexts[0].subarray(r.start, r.end+1).toString('hex');
103
+ console.log(` +0x${r.start.toString(16).padStart(3,'0')}..+0x${r.end.toString(16).padStart(3,'0')} (${(r.end-r.start+1).toString().padStart(3)}B) ${r.kind}${r.kind==='const'?' = '+slice:''}`);
104
+ }
105
+ }
106
+
107
+ // Find the key in the binary
108
+ console.log('\n=== locate key in HardwareManager binary ===');
109
+ const bin = fs.readFileSync(HWM);
110
+ let off = 0, hits = [];
111
+ while (true) {
112
+ const idx = bin.indexOf(KEY, off);
113
+ if (idx === -1) break;
114
+ hits.push(idx);
115
+ off = idx + 1;
116
+ }
117
+ for (const h of hits) {
118
+ const vmaddr = h + 0x100000000; // assume the standard Mach-O 64-bit base
119
+ console.log(` file 0x${h.toString(16)} (vmaddr ≈ 0x${vmaddr.toString(16)})`);
120
+ }
121
+ if (!hits.length) console.log(' NOT found in binary -- might be assembled at runtime');
@@ -0,0 +1,142 @@
1
+ // Verify the recovered KDF against a real session — no hardware needed.
2
+ //
3
+ // Inputs (all from one captured session, 2026-05-21 20:48-20:51):
4
+ // • the ephemeral private `d` — Path D lldb watchpoint dumped the stack at
5
+ // key-install time; d = FUN_100107650's local_48 = rsi-blob[0x40:0x60].
6
+ // • the installed AES key — Path D log + rsi-blob[0x00:0x20].
7
+ // • the handshake pcap — the TCP/2431 0x0F exchange of that session.
8
+ //
9
+ // The KDF claim: key = LE( X(d·S) XOR X(d·Q) )
10
+ // S = hardcoded static point, Q = Stick's pubkey from the handshake.
11
+ // We have d and key; we extract Q from the pcap and recompute. If the
12
+ // recomputed key equals the dumped key, the KDF is proven end-to-end.
13
+ //
14
+ // Usage: node tools/verify-kdf.mjs [rsi-blob] [pcap]
15
+
16
+ import fs from 'node:fs';
17
+ import { P256, STATIC_POINT_COMPRESSED } from './derive-dmx-key.mjs';
18
+
19
+ const RSI_BLOB = process.argv[2] || '/tmp/stick-pathD-blob-rsi_0x30a968b50.bin';
20
+ const PCAP = process.argv[3] || 'tools/captures/handshake-20260521-204953.pcap';
21
+ const KNOWN_KEY = '01bb3e12b9f2081ee0a0100fcd4f1ad3e716b453b9c1a69c41ce63e9af8ad087';
22
+
23
+ // ── P-256 affine arithmetic (independent bigint impl) ───────────────────────
24
+ const { p, a, b, Gx, Gy } = P256;
25
+ const mod = (x) => ((x % p) + p) % p;
26
+ const inv = (x) => modpow(mod(x), p - 2n, p);
27
+ function modpow(base, e, m) {
28
+ let r = 1n; base %= m;
29
+ while (e > 0n) { if (e & 1n) r = r * base % m; base = base * base % m; e >>= 1n; }
30
+ return r;
31
+ }
32
+ const onCurve = (P) => P && mod(P.y * P.y) === mod(P.x * P.x * P.x + a * P.x + b);
33
+ function dbl(P) {
34
+ if (!P) return null;
35
+ const l = mod((3n * P.x * P.x + a) * inv(2n * P.y));
36
+ const x = mod(l * l - 2n * P.x);
37
+ return { x, y: mod(l * (P.x - x) - P.y) };
38
+ }
39
+ function add(P, Q) {
40
+ if (!P) return Q; if (!Q) return P;
41
+ if (P.x === Q.x) return P.y === Q.y ? dbl(P) : null;
42
+ const l = mod((Q.y - P.y) * inv(Q.x - P.x));
43
+ const x = mod(l * l - P.x - Q.x);
44
+ return { x, y: mod(l * (P.x - x) - P.y) };
45
+ }
46
+ function mul(k, P) {
47
+ let R = null;
48
+ for (let i = 255n; i >= 0n; i--) { R = dbl(R); if ((k >> i) & 1n) R = add(R, P); }
49
+ return R;
50
+ }
51
+
52
+ // ── helpers ─────────────────────────────────────────────────────────────────
53
+ const G = { x: Gx, y: Gy };
54
+ const beHex = (v) => v.toString(16).padStart(64, '0');
55
+ const toLE = (v) => Buffer.from(beHex(v), 'hex').reverse(); // 32-byte LE limbs
56
+ const fromLE = (buf) => BigInt('0x' + Buffer.from(buf).reverse().toString('hex'));
57
+
58
+ function recoverY(x, evenParity) {
59
+ const rhs = mod(x * x * x + a * x + b);
60
+ let y = modpow(rhs, (p + 1n) / 4n, p);
61
+ if (mod(y * y) !== rhs) return null; // x not on the curve
62
+ if ((y & 1n) !== (evenParity ? 0n : 1n)) y = p - y;
63
+ return y;
64
+ }
65
+
66
+ // static point S — SEC1-decompress the raw 33-byte compressed point
67
+ const Sx = BigInt('0x' + STATIC_POINT_COMPRESSED.subarray(1, 33).toString('hex'));
68
+ const S = { x: Sx, y: recoverY(Sx, (STATIC_POINT_COMPRESSED[0] & 1) === 0) };
69
+
70
+ console.log('Stick-DE3 KDF — end-to-end verification\n');
71
+ let pass = true;
72
+ const t = (name, ok) => { console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}`); pass &&= ok; };
73
+
74
+ t('static point S is on P-256', onCurve(S));
75
+ t('generator G is on P-256', onCurve(G));
76
+
77
+ // ── load d, the ephemeral private ───────────────────────────────────────────
78
+ const blob = fs.readFileSync(RSI_BLOB);
79
+ const dumpedKey = blob.subarray(0, 32);
80
+ const d = fromLE(blob.subarray(0x40, 0x60)); // local_48, stored little-endian
81
+ t('dumped key matches Path D log', dumpedKey.toString('hex') === KNOWN_KEY);
82
+ console.log(` d = ${beHex(d)}`);
83
+ t('d is a valid scalar (0 < d < n)', d > 0n && d < P256.n);
84
+
85
+ // ── our public key = d·G ────────────────────────────────────────────────────
86
+ const ourPub = mul(d, G);
87
+ t('d·G is on P-256', onCurve(ourPub));
88
+ console.log(` our pub.x = ${beHex(ourPub.x)}`);
89
+
90
+ // ── scan the pcap for every valid 64-byte P-256 point (X‖Y) ──────────────────
91
+ const pcap = fs.readFileSync(PCAP);
92
+ function scanPoints(endian) {
93
+ const hits = [];
94
+ for (let o = 0; o + 64 <= pcap.length; o++) {
95
+ const xb = pcap.subarray(o, o + 32), yb = pcap.subarray(o + 32, o + 64);
96
+ const x = endian === 'le' ? fromLE(xb) : BigInt('0x' + xb.toString('hex'));
97
+ if (x === 0n || x >= p) continue;
98
+ const y = endian === 'le' ? fromLE(yb) : BigInt('0x' + yb.toString('hex'));
99
+ if (y === 0n || y >= p) continue;
100
+ if (onCurve({ x, y })) hits.push({ o, x, y, endian });
101
+ }
102
+ return hits;
103
+ }
104
+ let hits = scanPoints('le');
105
+ let wireEndian = 'little-endian';
106
+ if (!hits.some((h) => h.x === ourPub.x)) {
107
+ const be = scanPoints('be');
108
+ if (be.some((h) => h.x === ourPub.x)) { hits = be; wireEndian = 'big-endian'; }
109
+ }
110
+ t('our pubkey d·G is present in the handshake pcap', hits.some((h) => h.x === ourPub.x));
111
+ console.log(` wire point encoding = ${wireEndian}`);
112
+ console.log(` valid P-256 points found in pcap: ${hits.length}`);
113
+
114
+ // ── candidate Stick pubkeys = pcap points that aren't ours ──────────────────
115
+ const candidates = hits.filter((h) => h.x !== ourPub.x);
116
+
117
+ // ── recompute the key for each candidate Q ──────────────────────────────────
118
+ function deriveKey(Q) {
119
+ const xdS = toLE(mul(d, S).x);
120
+ const xdQ = toLE(mul(d, Q).x);
121
+ const k = Buffer.alloc(32);
122
+ for (let i = 0; i < 32; i++) k[i] = xdS[i] ^ xdQ[i];
123
+ return k;
124
+ }
125
+ let matchedQ = null;
126
+ for (const Q of candidates) {
127
+ if (deriveKey(Q).toString('hex') === KNOWN_KEY) { matchedQ = Q; break; }
128
+ }
129
+ t('a pcap pubkey Q reproduces the exact installed key', matchedQ !== null);
130
+
131
+ if (matchedQ) {
132
+ console.log(`\n Stick pubkey Q (from pcap @ offset 0x${matchedQ.o.toString(16)}):`);
133
+ console.log(` Q.x = ${beHex(matchedQ.x)}`);
134
+ console.log(` Q.y = ${beHex(matchedQ.y)}`);
135
+ console.log(`\n key = LE( X(d·S) XOR X(d·Q) )`);
136
+ console.log(` recomputed = ${deriveKey(matchedQ).toString('hex')}`);
137
+ console.log(` installed = ${KNOWN_KEY}`);
138
+ }
139
+
140
+ console.log(`\n${pass && matchedQ ? '✅ KDF VERIFIED END-TO-END — recovery is correct'
141
+ : '❌ verification failed'}`);
142
+ process.exit(pass && matchedQ ? 0 : 1);
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "nodenext",
6
+ "lib": ["ES2022"],
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "allowSyntheticDefaultImports": true,
15
+ "noImplicitAny": false
16
+ },
17
+ "include": ["src/"],
18
+ "exclude": ["**/*.spec.ts"]
19
+ }