@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.
- package/CLAUDE.md +176 -0
- package/README.md +146 -0
- package/config.schema.json +143 -0
- package/dist/cli/stick.d.ts +3 -0
- package/dist/cli/stick.d.ts.map +1 -0
- package/dist/cli/stick.js +176 -0
- package/dist/cli/stick.js.map +1 -0
- package/dist/color/cct.d.ts +3 -0
- package/dist/color/cct.d.ts.map +1 -0
- package/dist/color/cct.js +33 -0
- package/dist/color/cct.js.map +1 -0
- package/dist/color/dimmer.d.ts +3 -0
- package/dist/color/dimmer.d.ts.map +1 -0
- package/dist/color/dimmer.js +16 -0
- package/dist/color/dimmer.js.map +1 -0
- package/dist/color/hsvcct.d.ts +3 -0
- package/dist/color/hsvcct.d.ts.map +1 -0
- package/dist/color/hsvcct.js +52 -0
- package/dist/color/hsvcct.js.map +1 -0
- package/dist/color/index.d.ts +7 -0
- package/dist/color/index.d.ts.map +1 -0
- package/dist/color/index.js +28 -0
- package/dist/color/index.js.map +1 -0
- package/dist/color/parsers.d.ts +20 -0
- package/dist/color/parsers.d.ts.map +1 -0
- package/dist/color/parsers.js +232 -0
- package/dist/color/parsers.js.map +1 -0
- package/dist/color/rgb.d.ts +3 -0
- package/dist/color/rgb.d.ts.map +1 -0
- package/dist/color/rgb.js +32 -0
- package/dist/color/rgb.js.map +1 -0
- package/dist/color/rgbaw.d.ts +3 -0
- package/dist/color/rgbaw.d.ts.map +1 -0
- package/dist/color/rgbaw.js +48 -0
- package/dist/color/rgbaw.js.map +1 -0
- package/dist/color/rgbw.d.ts +3 -0
- package/dist/color/rgbw.d.ts.map +1 -0
- package/dist/color/rgbw.js +37 -0
- package/dist/color/rgbw.js.map +1 -0
- package/dist/color/rgbww.d.ts +3 -0
- package/dist/color/rgbww.d.ts.map +1 -0
- package/dist/color/rgbww.js +54 -0
- package/dist/color/rgbww.js.map +1 -0
- package/dist/color/types.d.ts +64 -0
- package/dist/color/types.d.ts.map +1 -0
- package/dist/color/types.js +91 -0
- package/dist/color/types.js.map +1 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +191 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +32 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +128 -0
- package/dist/controller.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +17 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +96 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +18 -0
- package/dist/platformAccessory.d.ts.map +1 -0
- package/dist/platformAccessory.js +81 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +14 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +15 -0
- package/dist/settings.js.map +1 -0
- package/dist/stick/frame.d.ts +16 -0
- package/dist/stick/frame.d.ts.map +1 -0
- package/dist/stick/frame.js +51 -0
- package/dist/stick/frame.js.map +1 -0
- package/dist/stick/kdf.d.ts +23 -0
- package/dist/stick/kdf.d.ts.map +1 -0
- package/dist/stick/kdf.js +90 -0
- package/dist/stick/kdf.js.map +1 -0
- package/dist/stick/protocol.d.ts +28 -0
- package/dist/stick/protocol.d.ts.map +1 -0
- package/dist/stick/protocol.js +78 -0
- package/dist/stick/protocol.js.map +1 -0
- package/dist/stick/session.d.ts +50 -0
- package/dist/stick/session.d.ts.map +1 -0
- package/dist/stick/session.js +301 -0
- package/dist/stick/session.js.map +1 -0
- package/examples/dmx.yaml +65 -0
- package/package.json +51 -0
- package/src/cli/stick.ts +198 -0
- package/src/color/cct.ts +40 -0
- package/src/color/dimmer.ts +24 -0
- package/src/color/hsvcct.ts +59 -0
- package/src/color/index.ts +34 -0
- package/src/color/parsers.ts +232 -0
- package/src/color/rgb.ts +33 -0
- package/src/color/rgbaw.ts +46 -0
- package/src/color/rgbw.ts +36 -0
- package/src/color/rgbww.ts +56 -0
- package/src/color/types.ts +150 -0
- package/src/config.ts +248 -0
- package/src/controller.ts +136 -0
- package/src/index.ts +9 -0
- package/src/platform.ts +118 -0
- package/src/platformAccessory.ts +102 -0
- package/src/settings.ts +18 -0
- package/src/stick/frame.ts +70 -0
- package/src/stick/kdf.ts +99 -0
- package/src/stick/protocol.ts +90 -0
- package/src/stick/session.ts +348 -0
- package/stick-de3.json +6 -0
- package/tools/aes-hook.c +1404 -0
- package/tools/analyze-pcap.mjs +143 -0
- package/tools/capture-aes-key.lldb +25 -0
- package/tools/capture-and-verify.sh +46 -0
- package/tools/capture-handshake.sh +59 -0
- package/tools/capture-send.sh +39 -0
- package/tools/crack-kdf.mjs +121 -0
- package/tools/crack-key.mjs +170 -0
- package/tools/decode-frame.mjs +110 -0
- package/tools/decrypt-011c.mjs +123 -0
- package/tools/decrypt-dmx.mjs +130 -0
- package/tools/decrypt-ssl2.ts +154 -0
- package/tools/derive-dmx-key.mjs +189 -0
- package/tools/diff-011c.mjs +127 -0
- package/tools/dump-pcap-summary.mjs +54 -0
- package/tools/dump-tcp-stream.mjs +123 -0
- package/tools/extract-011c.mjs +117 -0
- package/tools/extract-frame.mjs +78 -0
- package/tools/find-011c-key.mjs +140 -0
- package/tools/find-iv.mjs +153 -0
- package/tools/ghidra/README.md +136 -0
- package/tools/ghidra/__pycache__/dump-011c-callers.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-011c-handler.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-aes-candidates.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-anchors.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-auth2.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-authenticate.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-block-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-ctor-chain.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-curve.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-encompassing.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-handshake.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-kdf-chain.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-kdf-context.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-profile.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-real-encrypt.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-secret.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-setsoftware.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-stick3-vtable.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-swxref.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-vt-key-getter.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-md5.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-secret-users.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3-crypto.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3-device.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3a-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3anet-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-vtables.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/string-walk.cpython-313.pyc +0 -0
- package/tools/ghidra/dump-011c-callers.py +140 -0
- package/tools/ghidra/dump-011c-handler.py +158 -0
- package/tools/ghidra/dump-aes-candidates.py +99 -0
- package/tools/ghidra/dump-anchors.py +192 -0
- package/tools/ghidra/dump-auth2.py +50 -0
- package/tools/ghidra/dump-authenticate.py +71 -0
- package/tools/ghidra/dump-block-cipher.py +88 -0
- package/tools/ghidra/dump-cipher.py +106 -0
- package/tools/ghidra/dump-ctor-chain.py +107 -0
- package/tools/ghidra/dump-curve.py +112 -0
- package/tools/ghidra/dump-encompassing.py +146 -0
- package/tools/ghidra/dump-handshake.py +76 -0
- package/tools/ghidra/dump-kdf-chain.py +105 -0
- package/tools/ghidra/dump-kdf-context.py +72 -0
- package/tools/ghidra/dump-profile.py +59 -0
- package/tools/ghidra/dump-real-encrypt.py +90 -0
- package/tools/ghidra/dump-secret.py +84 -0
- package/tools/ghidra/dump-setsoftware.py +63 -0
- package/tools/ghidra/dump-stick3-vtable.py +182 -0
- package/tools/ghidra/dump-swxref.py +27 -0
- package/tools/ghidra/dump-vt-key-getter.py +170 -0
- package/tools/ghidra/find-cipher.py +210 -0
- package/tools/ghidra/find-ctor.py +109 -0
- package/tools/ghidra/find-md5.py +97 -0
- package/tools/ghidra/find-secret-users.py +98 -0
- package/tools/ghidra/find-stick3-crypto.py +190 -0
- package/tools/ghidra/find-stick3-device.py +111 -0
- package/tools/ghidra/find-stick3a-ctor.py +87 -0
- package/tools/ghidra/find-stick3anet-ctor.py +76 -0
- package/tools/ghidra/find-vtables.py +165 -0
- package/tools/ghidra/out-011c-callers-run.log +51 -0
- package/tools/ghidra/out-011c-run.log +50 -0
- package/tools/ghidra/out-vt-key-run.log +55 -0
- package/tools/ghidra/string-walk.py +180 -0
- package/tools/hmac-key.sh +51 -0
- package/tools/hwm-entitlements.plist +18 -0
- package/tools/inspect-plaintext.mjs +87 -0
- package/tools/lldb-capture-aes.py +137 -0
- package/tools/lldb_hmac_key.py +93 -0
- package/tools/lldb_pathA_scan.py +341 -0
- package/tools/lldb_pathC_keytrace.py +300 -0
- package/tools/lldb_pathD_kdf_watch.py +281 -0
- package/tools/pathA-launch-hwm.sh +86 -0
- package/tools/pathA-scan.sh +52 -0
- package/tools/pathC-keytrace.sh +49 -0
- package/tools/pathC-run.sh +71 -0
- package/tools/pathD-kdf-watch.sh +51 -0
- package/tools/quick-trigger.mjs +86 -0
- package/tools/run-hooked-hwmanager.sh +51 -0
- package/tools/scan-aes-schedule.mjs +135 -0
- package/tools/send_dmx.mjs +480 -0
- package/tools/stick-status.mjs +57 -0
- package/tools/test-aes-detector.mjs +100 -0
- package/tools/test-tcp-write.ts +274 -0
- package/tools/test-udp-dmx.ts +324 -0
- package/tools/test-x25519.mjs +87 -0
- package/tools/try-decrypt.ts +144 -0
- package/tools/try-hardcoded-key.mjs +86 -0
- package/tools/try-hardcoded-key.ts +103 -0
- package/tools/try-key.mjs +103 -0
- package/tools/verify-011c-key.mjs +121 -0
- package/tools/verify-kdf.mjs +142 -0
- 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
|
+
}
|