@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,110 @@
1
+ // Decode a captured 576-byte DMX frame with the recovered AES-256 key, and
2
+ // pin down the first-block IV construction.
3
+ //
4
+ // AES-256-CBC: blocks 1..33 decrypt from the key alone. Block 0 needs the IV:
5
+ // P0 = D(key, C0) XOR IV
6
+ // We compute D(key,C0), then try nonce-derived IV constructions and pick the
7
+ // one whose resulting P0 looks like DMX (consistent with blocks 1..33).
8
+ //
9
+ // Usage:
10
+ // node tools/decode-frame.mjs <frame.bin> [key-hex]
11
+ // (key defaults to the value recovered by Path C)
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 [, , framePath, keyArg] = process.argv;
20
+ if (!framePath) {
21
+ console.error('usage: node tools/decode-frame.mjs <frame.bin> [key-hex]');
22
+ process.exit(1);
23
+ }
24
+ const key = Buffer.from((keyArg || DEFAULT_KEY).replace(/\s/g, ''), 'hex');
25
+ if (key.length !== 32) { console.error('key must be 32 bytes'); process.exit(1); }
26
+
27
+ const frame = fs.readFileSync(framePath);
28
+ if (frame.length !== 576) { console.error(`need 576 bytes, got ${frame.length}`); process.exit(1); }
29
+
30
+ const header = frame.subarray(0, 0x20);
31
+ const body = frame.subarray(0x20, 0x240); // 544 B
32
+ const nonce = Buffer.from(header.subarray(0x18, 0x20));
33
+
34
+ console.log('header :', header.toString('hex').match(/../g).join(' '));
35
+ console.log(' magic =', header.subarray(0, 8).toString());
36
+ console.log(' seq =', header[0x17], ' nonce =', nonce.toString('hex'));
37
+ console.log(' key =', key.toString('hex'));
38
+ console.log();
39
+
40
+ // CBC-decrypt the whole body with IV=0:
41
+ // out[0:16] = D(key,C0) = IV XOR P0
42
+ // out[16:544] = real plaintext P1..P33
43
+ const d = crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
44
+ d.setAutoPadding(false);
45
+ const out = Buffer.concat([d.update(body), d.final()]);
46
+ const Dt0 = out.subarray(0, 16); // D(key,C0)
47
+ const known = out.subarray(16); // 528 bytes, real DMX
48
+
49
+ let zeros = 0;
50
+ for (const b of known) if (b === 0) zeros++;
51
+ console.log(`plaintext blocks 1..33 (528 B real DMX, ${(zeros / known.length * 100).toFixed(1)}% zero):`);
52
+ for (let o = 0; o < known.length; o += 32) {
53
+ const row = known.subarray(o, o + 32);
54
+ if (row.every(b => b === 0)) continue; // skip all-zero rows
55
+ console.log(` P[+0x${(o + 16).toString(16).padStart(3, '0')}] ${row.toString('hex').match(/../g).join(' ')}`);
56
+ }
57
+ console.log(' (all-zero rows omitted)');
58
+ console.log();
59
+
60
+ // --- recover the IV: P0 = D(key,C0) XOR IV --------------------------------
61
+ console.log(`D(key,C0) = ${Dt0.toString('hex')} (= IV XOR P0)`);
62
+ console.log();
63
+
64
+ const z8 = Buffer.alloc(8);
65
+ const fieldA = Buffer.from(header.subarray(0x0a, 0x12)); // varying 8-byte field
66
+ const blocks16 = [
67
+ ['nonce|0', Buffer.concat([nonce, z8])],
68
+ ['0|nonce', Buffer.concat([z8, nonce])],
69
+ ['nonce|nonce', Buffer.concat([nonce, nonce])],
70
+ ['fieldA|nonce', Buffer.concat([fieldA, nonce])],
71
+ ['nonce|fieldA', Buffer.concat([nonce, fieldA])],
72
+ ['hdr[0:16]', Buffer.from(header.subarray(0, 16))],
73
+ ['hdr[16:32]', Buffer.from(header.subarray(16, 32))],
74
+ ['nonce|nonceR', Buffer.concat([nonce, Buffer.from(nonce).reverse()])],
75
+ ['zero', Buffer.alloc(16)],
76
+ ];
77
+
78
+ function ecbEncrypt(block) {
79
+ const c = crypto.createCipheriv('aes-256-ecb', key, null);
80
+ c.setAutoPadding(false);
81
+ return Buffer.concat([c.update(block), c.final()]);
82
+ }
83
+
84
+ // Assuming P0 = 0 (DMX ch 1-16 off), the IV equals D(key,C0). Test which
85
+ // construction reproduces it — directly, or as AES-256(key, block).
86
+ console.log(`assuming P0 = 0 -> IV should equal D(key,C0) = ${Dt0.toString('hex')}`);
87
+ console.log();
88
+ console.log('testing IV constructions:');
89
+ let hit = null;
90
+ for (const [name, blk] of blocks16) {
91
+ if (blk.equals(Dt0)) {
92
+ console.log(` ✅ IV = ${name} (direct)`);
93
+ hit = `${name} (direct)`;
94
+ }
95
+ const enc = ecbEncrypt(blk);
96
+ if (enc.equals(Dt0)) {
97
+ console.log(` ✅ IV = AES-256-ECB(key, ${name})`);
98
+ hit = `AES-256-ECB(key, ${name})`;
99
+ }
100
+ }
101
+ if (!hit) {
102
+ console.log(' (no match — P0 may be non-zero, or IV uses other inputs)');
103
+ console.log();
104
+ console.log('Definitive next step: in HWM, set every channel to 0 (blackout),');
105
+ console.log('capture a frame, re-run this. Then P0 = 0 for sure and');
106
+ console.log('IV = D(key,C0) exactly — and these guesses will resolve it.');
107
+ } else {
108
+ console.log();
109
+ console.log(`✅ IV CONSTRUCTION: ${hit}`);
110
+ }
@@ -0,0 +1,123 @@
1
+ // Decrypt a captured 0x011c reply.
2
+ //
3
+ // Per FUN_1001bbca0 (Ghidra): the 240-byte ciphertext at reply[+0x2c] is
4
+ // AES-256-CBC encrypted; IV is reply[+0x1a:+0x2a]; expected plaintext
5
+ // length is 0x00ec (236 bytes -- last 4 of the 240B decrypt are slack/pad).
6
+ //
7
+ // We don't yet know the key. Try the two known candidates:
8
+ // 1. The static SEC1 P-256 point X-coord (32 bytes)
9
+ // 2. The XOR-deobfuscated 33-byte secret, first 32 bytes
10
+ //
11
+ // node tools/decrypt-011c.mjs <pcap>
12
+
13
+ import fs from 'node:fs';
14
+ import crypto from 'node:crypto';
15
+
16
+ function ipFromEth(pkt) {
17
+ if (pkt.length < 14) return null;
18
+ let off = 12, eth = pkt.readUInt16BE(off);
19
+ for (let i = 0; i < 2 && eth === 0x8100; i++) { off += 4; if (off + 2 > pkt.length) return null; eth = pkt.readUInt16BE(off); }
20
+ return eth === 0x0800 ? pkt.subarray(off + 2) : null;
21
+ }
22
+ function reassemble(segs) {
23
+ if (!segs.length) return Buffer.alloc(0);
24
+ segs.sort((a, b) => (a.seq - b.seq + 0x100000000) % 0x100000000);
25
+ const seen = new Set(); const out = [];
26
+ const base = segs[0].seq;
27
+ for (const s of segs) {
28
+ const k = `${s.seq}:${s.payload.length}`; if (seen.has(k)) continue; seen.add(k);
29
+ const off = (s.seq - base + 0x100000000) % 0x100000000;
30
+ while (out.length < off + s.payload.length) out.push(0);
31
+ for (let i = 0; i < s.payload.length; i++) out[off + i] = s.payload[i];
32
+ }
33
+ return Buffer.from(out);
34
+ }
35
+ function extractReplies(pcapPath) {
36
+ const buf = fs.readFileSync(pcapPath);
37
+ const le = buf.readUInt32LE(0) === 0xa1b2c3d4;
38
+ const streams = new Map();
39
+ let off = 24;
40
+ while (off + 16 <= buf.length) {
41
+ const capLen = le ? buf.readUInt32LE(off + 8) : buf.readUInt32BE(off + 8);
42
+ off += 16; if (off + capLen > buf.length) break;
43
+ const pkt = buf.subarray(off, off + capLen); off += capLen;
44
+ const ip = ipFromEth(pkt); if (!ip || ip.length < 20 || ((ip[0] >> 4) & 0xf) !== 4) continue;
45
+ const ihl = (ip[0] & 0x0f) * 4; if (ip[9] !== 6) continue;
46
+ const tcp = ip.subarray(ihl);
47
+ const sp = tcp.readUInt16BE(0); if (sp !== 2431) continue;
48
+ const seq = tcp.readUInt32BE(4); const dataOff = (tcp[12] >> 4) * 4;
49
+ const payload = tcp.subarray(dataOff); if (!payload.length) continue;
50
+ const src = `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${sp}`;
51
+ const dst = `${ip[16]}.${ip[17]}.${ip[18]}.${ip[19]}:${tcp.readUInt16BE(2)}`;
52
+ const k = [src, dst].sort().join('<->');
53
+ if (!streams.has(k)) streams.set(k, []);
54
+ streams.get(k).push({ seq, payload });
55
+ }
56
+ const replies = [];
57
+ for (const [k, segs] of streams) {
58
+ const r = reassemble(segs);
59
+ for (let i = 0; i + 24 <= r.length; i++) {
60
+ const m = r.subarray(i, i + 8).toString();
61
+ if (m !== 'Stick_3A' && m !== 'LSAG_ALL') continue;
62
+ if (r.readUInt16LE(i + 8) !== 0x011c) continue;
63
+ const len = r.readUInt32LE(i + 0x14);
64
+ if (len < 0x18 || len > 0x400 || i + len > r.length) continue;
65
+ const msg = r.subarray(i, i + len);
66
+ if (msg.length !== 284) continue;
67
+ replies.push({ pcap: pcapPath, stream: k, msg });
68
+ }
69
+ }
70
+ return replies;
71
+ }
72
+
73
+ // Candidate AES-256 keys.
74
+ const KEYS = [
75
+ ['static SEC1 X (P-256 point)', '28081290396d063e5114526ed978b926558f6ba1c96ad2facf76fffb3ffea0fe'],
76
+ ['XOR-deobfuscated (first 32)', '527a5b46c56f3a5e670b4f0e338727d94737ec0fc4af0dba93a51d93965191d0'],
77
+ // also reversed-endian variants just in case
78
+ ['static SEC1 X reversed', '28081290396d063e5114526ed978b926558f6ba1c96ad2facf76fffb3ffea0fe'.match(/../g).reverse().join('')],
79
+ ['XOR-deobf reversed', '527a5b46c56f3a5e670b4f0e338727d94737ec0fc4af0dba93a51d93965191d0'.match(/../g).reverse().join('')],
80
+ ];
81
+
82
+ function tryDecrypt(label, keyHex, iv, ct) {
83
+ try {
84
+ const key = Buffer.from(keyHex, 'hex');
85
+ if (key.length !== 32) return null;
86
+ const d = crypto.createDecipheriv('aes-256-cbc', key, iv);
87
+ d.setAutoPadding(false);
88
+ const pt = Buffer.concat([d.update(ct), d.final()]);
89
+ // score: how printable / structured?
90
+ let printable = 0, zero = 0;
91
+ for (const b of pt) {
92
+ if (b === 0) zero++;
93
+ else if (b >= 0x20 && b < 0x7f) printable++;
94
+ }
95
+ return { label, keyHex, pt, printable, zero, total: pt.length };
96
+ } catch (e) {
97
+ return { label, keyHex, err: e.message };
98
+ }
99
+ }
100
+
101
+ const pcaps = process.argv.slice(2);
102
+ if (!pcaps.length) { console.error('usage: node tools/decrypt-011c.mjs <pcap> [<pcap>...]'); process.exit(1); }
103
+
104
+ for (const p of pcaps) {
105
+ const reps = extractReplies(p);
106
+ console.log(`\n=== ${p}: ${reps.length} 0x011c replies ===`);
107
+ for (const r of reps) {
108
+ const tok = r.msg.readUInt32LE(0x0a);
109
+ const iv = r.msg.subarray(0x1a, 0x2a);
110
+ const ct = r.msg.subarray(0x2c, 0x2c + 240);
111
+ console.log(`\n token=${tok} IV=${iv.toString('hex')}`);
112
+ for (const [label, keyHex] of KEYS) {
113
+ const res = tryDecrypt(label, keyHex, iv, ct);
114
+ if (!res) continue;
115
+ if (res.err) { console.log(` ${label.padEnd(28)} ERR ${res.err}`); continue; }
116
+ console.log(` ${label.padEnd(28)} printable=${res.printable.toString().padStart(3)}/${res.total} zero=${res.zero.toString().padStart(3)} first16=${res.pt.subarray(0,16).toString('hex')}`);
117
+ if (res.printable > 40 || res.zero > 20) {
118
+ console.log(` preview: ${res.pt.subarray(0, 64).toString('hex')}`);
119
+ console.log(` ascii : ${res.pt.subarray(0, 96).toString('binary').replace(/[^\x20-\x7e]/g, '.')}`);
120
+ }
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,130 @@
1
+ // End-to-end decrypt test: handshake pcap + ephemeral private `d`
2
+ // → KDF (P-256 double-ECDH) → AES-256 key
3
+ // → AES-256-CBC decrypt the session's UDP DMX frames
4
+ // → the 512 live DMX channel values.
5
+ //
6
+ // This wires the recovered KDF (derive-dmx-key.mjs) and the recovered cipher
7
+ // into one pipeline and proves it on a real captured session.
8
+ //
9
+ // VERIFIED: on handshake-20260521-203416.pcap + the Path D 20:34 rsi blob it
10
+ // derives key af90ce58… and decrypts that pcap's DMX at a 100% zero-ratio.
11
+ //
12
+ // Usage:
13
+ // node tools/decrypt-dmx.mjs <pcap> <d>
14
+ // <pcap> a capture containing the TCP/2431 handshake + UDP DMX stream
15
+ // <d> the ephemeral private key — 64-hex-chars, or the path to an
16
+ // lldb Path D rsi blob (d = blob[0x40:0x60], little-endian)
17
+
18
+ import fs from 'node:fs';
19
+ import crypto from 'node:crypto';
20
+ import { deriveDmxKey, wireToPoint, pointToWire } from './derive-dmx-key.mjs';
21
+
22
+ const [, , pcapPath, dArg] = process.argv;
23
+ if (!pcapPath || !dArg) {
24
+ console.error('usage: node tools/decrypt-dmx.mjs <pcap> <d-hex | rsi-blob>');
25
+ process.exit(1);
26
+ }
27
+
28
+ // ── load the ephemeral private d ─────────────────────────────────────────────
29
+ let dLE;
30
+ if (/^[0-9a-fA-F]{64}$/.test(dArg)) {
31
+ dLE = Buffer.from(dArg, 'hex'); // already little-endian limbs
32
+ } else {
33
+ dLE = fs.readFileSync(dArg).subarray(0x40, 0x60); // rsi blob: local_48
34
+ }
35
+ const dBE = Buffer.from(dLE).reverse(); // node ECDH wants big-endian
36
+ const ecdh = crypto.createECDH('prime256v1');
37
+ ecdh.setPrivateKey(dBE);
38
+ const ourWire = pointToWire(ecdh.getPublicKey(null, 'uncompressed'));
39
+
40
+ const pcap = fs.readFileSync(pcapPath);
41
+
42
+ // ── stage 1: KDF — find the Stick pubkey Q in the handshake, derive the key ──
43
+ // Q is the 64-byte point in the opcode-0x0F response. Collect every 64-byte
44
+ // window that follows a "Stick_3A"+0x0F header and trial-derive: the correct Q
45
+ // is the one whose key decrypts DMX.
46
+ const MAGIC = Buffer.from('Stick_3A');
47
+
48
+ function dmxFrames(buf) {
49
+ const out = [];
50
+ for (let o = 0; o + 576 <= buf.length; o++) {
51
+ if (buf.subarray(o, o + 8).equals(MAGIC) && buf[o + 8] === 0x19 && buf[o + 9] === 0x00) {
52
+ out.push(buf.subarray(o, o + 576));
53
+ o += 575;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // AES-256-CBC decrypt one 576-byte frame -> 544-byte plaintext body
60
+ function decryptFrame(frame, key) {
61
+ const hdr = frame.subarray(0, 32);
62
+ const iv = Buffer.concat([hdr.subarray(0x0a, 0x12), hdr.subarray(0x18, 0x20)]);
63
+ const dec = crypto.createDecipheriv('aes-256-cbc', key, iv);
64
+ dec.setAutoPadding(false);
65
+ return Buffer.concat([dec.update(frame.subarray(32, 576)), dec.final()]);
66
+ }
67
+ const zeroRatio = (dmx512) => {
68
+ let z = 0;
69
+ for (const b of dmx512) if (b === 0) z++;
70
+ return z / dmx512.length;
71
+ };
72
+
73
+ const frames = dmxFrames(pcap);
74
+ if (frames.length === 0) { console.error('no 576-byte DMX frames in pcap'); process.exit(1); }
75
+
76
+ // candidate Q windows: 64 bytes after each "Stick_3A 0f 00" header (+0x12 req / +0x16 resp)
77
+ const candidates = [];
78
+ for (let o = 0; o + 0x56 <= pcap.length; o++) {
79
+ if (pcap.subarray(o, o + 8).equals(MAGIC) && pcap[o + 8] === 0x0f && pcap[o + 9] === 0x00) {
80
+ for (const off of [0x12, 0x16]) {
81
+ const w = pcap.subarray(o + off, o + off + 64);
82
+ if (w.length === 64 && !w.equals(ourWire)) candidates.push(Buffer.from(w));
83
+ }
84
+ }
85
+ }
86
+
87
+ let key = null, stickQ = null;
88
+ for (const cand of candidates) {
89
+ let k;
90
+ try { k = deriveDmxKey(ecdh, wireToPoint(cand)); } catch { continue; }
91
+ if (zeroRatio(decryptFrame(frames[0], k).subarray(16, 528)) > 0.5) { key = k; stickQ = cand; break; }
92
+ }
93
+ if (!key) {
94
+ console.error('KDF: no handshake pubkey in the pcap yields a decryptable key');
95
+ process.exit(1);
96
+ }
97
+
98
+ console.log('Stick-DE3 — end-to-end DMX decrypt\n');
99
+ console.log(` pcap : ${pcapPath}`);
100
+ console.log(` ephemeral d·G : ${ourWire.subarray(0, 8).toString('hex')}… (our pubkey)`);
101
+ console.log(` Stick pubkey Q : ${stickQ.subarray(0, 8).toString('hex')}…`);
102
+ console.log(` derived AES key : ${key.toString('hex')}`);
103
+ console.log(` DMX frames : ${frames.length}\n`);
104
+
105
+ // ── stage 2: decrypt the DMX stream ─────────────────────────────────────────
106
+ let bestIdx = 0, bestRatio = 0;
107
+ const p0set = new Set();
108
+ for (let i = 0; i < frames.length; i++) {
109
+ const pt = decryptFrame(frames[i], key);
110
+ p0set.add(pt.subarray(0, 16).toString('hex'));
111
+ const r = zeroRatio(pt.subarray(16, 528));
112
+ if (r > bestRatio) { bestRatio = r; bestIdx = i; }
113
+ }
114
+ console.log(` best frame #${bestIdx}: ${(bestRatio * 100).toFixed(1)}% of 512 DMX channels are zero`);
115
+ console.log(` internal header P0 distinct values across stream: ${p0set.size}`);
116
+
117
+ // show the lit channels of a representative frame (first with any non-zero)
118
+ let show = frames.findIndex((f) => zeroRatio(decryptFrame(f, key).subarray(16, 528)) < 1);
119
+ if (show < 0) show = 0;
120
+ const pt = decryptFrame(frames[show], key);
121
+ const dmx = pt.subarray(16, 528);
122
+ const lit = [];
123
+ for (let ch = 0; ch < 512; ch++) if (dmx[ch] !== 0) lit.push(`ch${ch + 1}=${dmx[ch]}`);
124
+ console.log(`\n frame #${show} — P0=${pt.subarray(0, 16).toString('hex')}`);
125
+ console.log(` lit DMX channels (${lit.length}): ${lit.slice(0, 40).join(' ')}${lit.length > 40 ? ' …' : ''}`);
126
+
127
+ const ok = bestRatio > 0.9;
128
+ console.log(`\n${ok ? '✅ KDF + cipher VERIFIED — real DMX recovered end-to-end'
129
+ : '❌ decryption did not yield plausible DMX'}`);
130
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,154 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ // AraCrypt LFSR stream cipher - ported from HakanL's C# gist
4
+ // https://gist.github.com/HakanL/f67fb9452d086856f105d64bc13a3f46
5
+
6
+ class AraCrypt {
7
+ private lfsrA = 0x13579BDF
8
+ private lfsrB = 0x2468ACE0
9
+ private lfsrC = 0xFDB97531
10
+
11
+ private readonly maskA = 0x80000062
12
+ private readonly maskB = 0x40000020
13
+ private readonly maskC = 0x10000002
14
+ private readonly rot0A = 0x7FFFFFFF
15
+ private readonly rot0B = 0x3FFFFFFF
16
+ private readonly rot0C = 0x0FFFFFFF
17
+ private readonly rot1A = 0x80000000
18
+ private readonly rot1B = 0xC0000000
19
+ private readonly rot1C = 0xF0000000
20
+
21
+ setKey(key: string): void {
22
+ // Pad to at least 12 chars by repeating individual characters
23
+ let seed = key
24
+ let idx = 0
25
+ while (seed.length < 12) {
26
+ seed += seed[idx++]
27
+ }
28
+
29
+ // Reset LFSRs
30
+ this.lfsrA = 0x13579BDF
31
+ this.lfsrB = 0x2468ACE0
32
+ this.lfsrC = 0xFDB97531
33
+
34
+ // Distribute: chars 0-3 → A, chars 4-7 → B, chars 8-11 → C
35
+ for (let i = 0; i < 4; i++) {
36
+ this.lfsrA = (((this.lfsrA << 8) >>> 0) | seed.charCodeAt(i + 0)) >>> 0
37
+ this.lfsrB = (((this.lfsrB << 8) >>> 0) | seed.charCodeAt(i + 4)) >>> 0
38
+ this.lfsrC = (((this.lfsrC << 8) >>> 0) | seed.charCodeAt(i + 8)) >>> 0
39
+ }
40
+
41
+ if (this.lfsrA === 0) this.lfsrA = 0x13579BDF
42
+ if (this.lfsrB === 0) this.lfsrB = 0x2468ACE0
43
+ if (this.lfsrC === 0) this.lfsrC = 0xFDB97531
44
+ }
45
+
46
+ transformByte(input: number): number {
47
+ let crypto = 0
48
+ let outB = (this.lfsrB & 1) >>> 0
49
+ let outC = (this.lfsrC & 1) >>> 0
50
+
51
+ for (let i = 0; i < 8; i++) {
52
+ if ((this.lfsrA & 1) !== 0) {
53
+ // Clock A with feedback, then clock B
54
+ this.lfsrA = ((((this.lfsrA ^ this.maskA) >>> 0) >>> 1) | this.rot1A) >>> 0
55
+ if ((this.lfsrB & 1) !== 0) {
56
+ this.lfsrB = ((((this.lfsrB ^ this.maskB) >>> 0) >>> 1) | this.rot1B) >>> 0
57
+ outB = 1
58
+ } else {
59
+ this.lfsrB = ((this.lfsrB >>> 1) & this.rot0B) >>> 0
60
+ outB = 0
61
+ }
62
+ } else {
63
+ // Clock A without feedback, then clock C
64
+ this.lfsrA = ((this.lfsrA >>> 1) & this.rot0A) >>> 0
65
+ if ((this.lfsrC & 1) !== 0) {
66
+ this.lfsrC = ((((this.lfsrC ^ this.maskC) >>> 0) >>> 1) | this.rot1C) >>> 0
67
+ outC = 1
68
+ } else {
69
+ this.lfsrC = ((this.lfsrC >>> 1) & this.rot0C) >>> 0
70
+ outC = 0
71
+ }
72
+ }
73
+ crypto = ((crypto << 1) | (outB ^ outC)) & 0xFF
74
+ }
75
+
76
+ input = (input ^ crypto) & 0xFF
77
+ if (input === 0) input = crypto
78
+ return input
79
+ }
80
+
81
+ transform(data: Buffer): Buffer {
82
+ const out = Buffer.alloc(data.length)
83
+ for (let i = 0; i < data.length; i++) {
84
+ out[i] = this.transformByte(data[i])
85
+ }
86
+ return out
87
+ }
88
+ }
89
+
90
+ const keyCandidates = [
91
+ 'Daslight',
92
+ 'DasLight',
93
+ 'daslight',
94
+ 'DasCrypt',
95
+ 'DasCryptKey',
96
+ 'DasCryptV4',
97
+ 'Daslight4',
98
+ 'DAS',
99
+ 'Nicolaudie-Sunlite',
100
+ 'Nicolaudie-SUNLITE',
101
+ 'NICOLAUDIE-SUNLITE',
102
+ 'Nico-Sunlite',
103
+ 'NicolaudieSunlite',
104
+ 'Nicolaudie',
105
+ 'nicolaudie',
106
+ 'SUNLITE',
107
+ 'Sunlite',
108
+ 'SunliteSuite',
109
+ 'Sunlite Suite',
110
+ 'Daslight-Sunlite',
111
+ 'DaslightSunlite',
112
+ 'Daslight Sunlite',
113
+ ]
114
+
115
+ const filePath = process.argv[2] || '/Applications/EsaPro2/ScanLibrary/_imported/WAC Lighting DC-WD05 Architectural.ssl2'
116
+ console.log(`Reading: ${filePath}`)
117
+ const data = readFileSync(filePath)
118
+ console.log(`File size: ${data.length} bytes`)
119
+ console.log(`First 16 bytes: ${Array.from(data.subarray(0, 16)).map(b => b.toString(16).padStart(2, '0')).join(' ')}`)
120
+
121
+ console.log(`\nTrying ${keyCandidates.length} key candidates...\n`)
122
+
123
+ for (const key of keyCandidates) {
124
+ const crypt = new AraCrypt()
125
+ crypt.setKey(key)
126
+ const decrypted = crypt.transform(data)
127
+ const head = decrypted.subarray(0, 100).toString('latin1')
128
+
129
+ const isXml = head.includes('<?xml') || head.includes('<DLM') || head.includes('<DLMFILE')
130
+
131
+ if (isXml) {
132
+ console.log(`*** KEY FOUND: "${key}" ***`)
133
+ console.log(`First 300 chars:\n${decrypted.subarray(0, 300).toString('latin1')}`)
134
+ const outPath = filePath.replace('.ssl2', '.xml')
135
+ writeFileSync(outPath, decrypted)
136
+ console.log(`\nDecrypted XML written to: ${outPath}`)
137
+ process.exit(0)
138
+ } else {
139
+ const preview = Array.from(decrypted.subarray(0, 12)).map(b =>
140
+ b >= 32 && b < 127 ? String.fromCharCode(b) : '.'
141
+ ).join('')
142
+ console.log(` "${key}" → ${preview}`)
143
+ }
144
+ }
145
+
146
+ console.log('\nNo key matched. Dumping details for top candidates:\n')
147
+ for (const key of keyCandidates.slice(0, 6)) {
148
+ const crypt = new AraCrypt()
149
+ crypt.setKey(key)
150
+ const decrypted = crypt.transform(data)
151
+ console.log(`Key "${key}":`)
152
+ console.log(` hex: ${Array.from(decrypted.subarray(0, 40)).map(b => b.toString(16).padStart(2, '0')).join(' ')}`)
153
+ console.log(` asc: ${Array.from(decrypted.subarray(0, 40)).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join('')}`)
154
+ }