@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,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
|
+
}
|