dsc-itv2-client 1.0.7

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/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * DSC ITV2 Protocol Client Library
3
+ * Main exports for the library
4
+ */
5
+
6
+ // Main client class
7
+ export { ITV2Client } from './ITV2Client.js';
8
+
9
+ // Parsers and utilities for advanced usage
10
+ export { parseZoneStatus, formatZoneStatus, parsePartitionStatus, formatPartitionStatus, parseCommandResponse, parseCommandError } from './response-parsers.js';
11
+
12
+ // Constants
13
+ export { CMD, CMD_NAMES } from './itv2-session.js';
14
+ export { Commands, CommandNames, getCommandName, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_UDP_PORT, DEFAULT_TCP_PORT } from './constants.js';
15
+
16
+ // Utility functions
17
+ export { toHex, fromHex, hexDump, timestamp, log, crc16, byteUnstuff, byteStuff, parseCommand, makeCommand } from './utils.js';
@@ -0,0 +1,310 @@
1
+ /**
2
+ * ITV2 Encryption Module
3
+ * Based on DSC-TLink reverse engineering
4
+ *
5
+ * Type 1 Encryption:
6
+ * - Uses Integration ID from [422] (NOT Access Code from [423]!)
7
+ * - Key derivation: first 8 hex chars of Integration ID, repeated 4 times → 16 bytes
8
+ * - Example: Integration ID "250228754876" → key "25022875" × 4 = "25022875250228752502287525022875"
9
+ * - Panel sends 48-byte initializer: [16 check bytes][32 ciphertext]
10
+ * - AES-ECB decrypt ciphertext, verify check bytes match even indexes, extract key from odd indexes
11
+ *
12
+ * Type 2 Encryption:
13
+ * - Uses 32-digit Integration Access Code from [700]
14
+ * - Direct AES-ECB encryption of 16-byte initializer
15
+ * - Same transform for encode local and decode remote
16
+ *
17
+ * Key Exchange:
18
+ * - Each side generates 32 random bytes
19
+ * - Even indexes become check bytes (sent in plaintext for verification)
20
+ * - Odd indexes become the AES session key
21
+ * - Ciphertext = AES-ECB-Encrypt(random32, derivedKey)
22
+ * - Initializer = [checkBytes 16][ciphertext 32] = 48 bytes
23
+ *
24
+ * Session Keys:
25
+ * - Your SEND key = derived from OTHER side's initializer (odd indexes of their decrypted random bytes)
26
+ * - Your RECEIVE key = odd indexes of YOUR random bytes (you already know this when you generate)
27
+ */
28
+
29
+ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
30
+
31
+ /**
32
+ * Transform Type 1 key string to 16-byte AES key
33
+ *
34
+ * Based on SDK analysis: The Access Code from [423] is used (8 digits)
35
+ * The key is derived by treating the 8 ASCII characters as bytes and repeating twice
36
+ * Example: "28754876" → ASCII bytes [0x32,0x33,0x34,0x32,0x35,0x33,0x35,0x32] repeated 2x = 16 bytes
37
+ *
38
+ * @param {string} keyString - 8-digit access code
39
+ * @param {string} method - 'ascii' (default) or 'hex'
40
+ */
41
+ export function transformType1KeyString(keyString, method = 'ascii') {
42
+ if (keyString.length < 8) {
43
+ throw new Error(`Access Code must be at least 8 digits, got ${keyString.length}`);
44
+ }
45
+ const first8 = keyString.substring(0, 8);
46
+
47
+ if (method === 'ascii') {
48
+ // ASCII method: each digit becomes its ASCII byte value, repeated twice for 16 bytes
49
+ // "28754876" → [0x32, 0x33, 0x34, 0x32, 0x35, 0x33, 0x35, 0x32] × 2
50
+ const key = Buffer.alloc(16);
51
+ for (let i = 0; i < 8; i++) {
52
+ key[i] = first8.charCodeAt(i);
53
+ key[i + 8] = first8.charCodeAt(i);
54
+ }
55
+ return key;
56
+ } else {
57
+ // Hex method: treat as hex string, repeat 4 times → 16 bytes
58
+ // "28754876" → 0x23 0x42 0x53 0x52 × 4
59
+ const hexString = first8 + first8 + first8 + first8;
60
+ return Buffer.from(hexString, 'hex');
61
+ }
62
+ }
63
+
64
+ /**
65
+ * AES-ECB encrypt with zero padding
66
+ */
67
+ export function aesEcbEncrypt(key, data) {
68
+ // Pad to 16-byte boundary
69
+ const padded = Buffer.alloc(Math.ceil(data.length / 16) * 16);
70
+ data.copy(padded);
71
+
72
+ const cipher = createCipheriv('aes-128-ecb', key, null);
73
+ cipher.setAutoPadding(false);
74
+ return Buffer.concat([cipher.update(padded), cipher.final()]);
75
+ }
76
+
77
+ /**
78
+ * AES-ECB decrypt with zero padding
79
+ */
80
+ export function aesEcbDecrypt(key, data) {
81
+ const decipher = createDecipheriv('aes-128-ecb', key, null);
82
+ decipher.setAutoPadding(false);
83
+ return Buffer.concat([decipher.update(data), decipher.final()]);
84
+ }
85
+
86
+ /**
87
+ * Get bytes at even indexes (0, 2, 4, ...)
88
+ */
89
+ function evenIndexes(bytes) {
90
+ const result = [];
91
+ for (let i = 0; i < bytes.length; i += 2) {
92
+ result.push(bytes[i]);
93
+ }
94
+ return Buffer.from(result);
95
+ }
96
+
97
+ /**
98
+ * Get bytes at odd indexes (1, 3, 5, ...)
99
+ */
100
+ function oddIndexes(bytes) {
101
+ const result = [];
102
+ for (let i = 1; i < bytes.length; i += 2) {
103
+ result.push(bytes[i]);
104
+ }
105
+ return Buffer.from(result);
106
+ }
107
+
108
+ /**
109
+ * Parse Type 1 initializer from panel's REQUEST_ACCESS payload
110
+ *
111
+ * The panel sends a 48-byte initializer:
112
+ * - Bytes 0-15: Check bytes (even indexes of plaintext)
113
+ * - Bytes 16-47: AES-ECB encrypted data (32 bytes)
114
+ *
115
+ * After decryption, the 32-byte plaintext contains:
116
+ * - Even indexes → should match check bytes
117
+ * - Odd indexes → the AES key for decrypting panel messages
118
+ *
119
+ * Key derivation attempts multiple methods:
120
+ * - Access Code from [423] (8 digits) with ASCII and hex encoding
121
+ * - Integration ID from [422] (12 digits) with ASCII and hex encoding
122
+ *
123
+ * @param {string} accessCode - 8-digit access code from [423]
124
+ * @param {Buffer} remoteInitializer - 48-byte payload from REQUEST_ACCESS
125
+ * @param {string} integrationId - Optional 12-digit integration ID from [422]
126
+ * @returns {Buffer} 16-byte AES key for decrypting messages from panel
127
+ */
128
+ export function parseType1Initializer(accessCode, remoteInitializer, integrationId = null, verbose = false) {
129
+ if (remoteInitializer.length !== 48) {
130
+ throw new Error(`Type 1 initializer must be 48 bytes, got ${remoteInitializer.length}`);
131
+ }
132
+
133
+ const checkBytes = remoteInitializer.slice(0, 16);
134
+ const cipherText = remoteInitializer.slice(16, 48);
135
+
136
+ const first8 = accessCode.substring(0, 8);
137
+
138
+ // Verbose logging helper
139
+ const log = verbose ? console.log : () => {};
140
+
141
+ // Key derivation methods to try (various interpretations)
142
+ const keyMethods = [
143
+ // Method 1: ASCII bytes of 8-digit code, repeated twice = 16 bytes
144
+ { name: 'ascii-repeat-2x', key: transformType1KeyString(accessCode, 'ascii') },
145
+ // Method 2: Hex interpretation of code, repeated 4 times = 16 bytes
146
+ { name: 'hex-repeat-4x', key: transformType1KeyString(accessCode, 'hex') },
147
+ // Method 3: Just the first 8 chars as ASCII (padded with zeros)
148
+ { name: 'ascii-8-padded', key: Buffer.from(first8.padEnd(16, '\0'), 'ascii') },
149
+ // Method 4: ASCII of code truncated/padded to 16 bytes
150
+ { name: 'ascii-16-trunc', key: Buffer.from(accessCode.padEnd(16, '0').substring(0, 16), 'ascii') },
151
+ // Method 5: BCD encoding - each digit becomes a nibble, repeated to 16 bytes
152
+ { name: 'bcd-repeat', key: Buffer.from(first8.split('').map((d,i) => i%2===0 ? (parseInt(d)<<4) : parseInt(first8[i-1])*16+parseInt(d)).filter((_,i)=>i%2===1).concat([0,0,0,0,0,0,0,0,0,0,0,0]).slice(0,16)) },
153
+ ];
154
+
155
+ // Also try with Integration ID if provided (the SDK might use Integration ID for key derivation)
156
+ if (integrationId && integrationId.length >= 8) {
157
+ const intId8 = integrationId.substring(0, 8);
158
+ keyMethods.push(
159
+ // Integration ID as ASCII bytes repeated twice
160
+ { name: 'intid-ascii-2x', key: (() => { const k = Buffer.alloc(16); for(let i=0;i<8;i++){k[i]=intId8.charCodeAt(i);k[i+8]=intId8.charCodeAt(i);} return k; })() },
161
+ // Integration ID as hex repeated 4 times
162
+ { name: 'intid-hex-4x', key: Buffer.from(intId8+intId8+intId8+intId8, 'hex') },
163
+ // Integration ID first 16 chars as ASCII
164
+ { name: 'intid-ascii-16', key: Buffer.from(integrationId.padEnd(16, '0').substring(0, 16), 'ascii') },
165
+ );
166
+ }
167
+
168
+ log(`[Type1] Access code: ${accessCode}`);
169
+ log(`[Type1] Check bytes: ${checkBytes.toString('hex')}`);
170
+ log(`[Type1] Ciphertext: ${cipherText.toString('hex')}`);
171
+
172
+ for (const method of keyMethods) {
173
+ try {
174
+ if (method.key.length !== 16) {
175
+ log(`[Type1] Skipping ${method.name}: invalid key length ${method.key.length}`);
176
+ continue;
177
+ }
178
+
179
+ log(`[Type1] Trying ${method.name}: key=${method.key.toString('hex')}`);
180
+
181
+ // Decrypt the ciphertext
182
+ const plainText = aesEcbDecrypt(method.key, cipherText);
183
+ log(`[Type1] Decrypted: ${plainText.toString('hex')}`);
184
+
185
+ // Extract even and odd indexes
186
+ const calcEven = evenIndexes(plainText);
187
+ const calcOdd = oddIndexes(plainText);
188
+ log(`[Type1] Even idx: ${calcEven.toString('hex')}`);
189
+ log(`[Type1] Odd idx: ${calcOdd.toString('hex')}`);
190
+
191
+ // Check if even indexes match check bytes → odd indexes are the key
192
+ if (checkBytes.equals(calcEven)) {
193
+ log(`[Type1] ✓ SUCCESS with ${method.name} (check=even, key=odd)`);
194
+ return calcOdd;
195
+ }
196
+
197
+ // Check if odd indexes match check bytes → even indexes are the key
198
+ if (checkBytes.equals(calcOdd)) {
199
+ log(`[Type1] ✓ SUCCESS with ${method.name} (check=odd, key=even)`);
200
+ return calcEven;
201
+ }
202
+
203
+ log(`[Type1] No match for ${method.name}`);
204
+ } catch (err) {
205
+ log(`[Type1] Error with ${method.name}: ${err.message}`);
206
+ }
207
+ }
208
+
209
+ // If no method worked, try the primary method and return without verification
210
+ // This allows debugging to continue even if check bytes don't match
211
+ log(`[Type1] WARNING: No method matched check bytes, using ascii-repeat-2x without verification`);
212
+ const key = transformType1KeyString(accessCode, 'ascii');
213
+ const plainText = aesEcbDecrypt(key, cipherText);
214
+
215
+ log(`[Type1] Decrypted (no verify): ${plainText.toString('hex')}`);
216
+ const derivedKey = oddIndexes(plainText);
217
+ log(`[Type1] Derived key (odd indexes): ${derivedKey.toString('hex')}`);
218
+
219
+ return derivedKey;
220
+ }
221
+
222
+ /**
223
+ * Generate Type 1 initializer and local AES key
224
+ *
225
+ * Creates a 48-byte initializer for key exchange:
226
+ * - Generate 32 random bytes
227
+ * - Even indexes become check bytes (16 bytes, sent in plaintext)
228
+ * - Odd indexes become our receive key (16 bytes)
229
+ * - Encrypt the 32 random bytes with derived key
230
+ * - Send [16 check bytes][32 ciphertext]
231
+ *
232
+ * @param {string} accessCode - 8-digit access code from [423]
233
+ * @param {string} method - Key derivation method: 'ascii' (default) or 'hex'
234
+ * @returns {{ initializer: Buffer, localKey: Buffer }}
235
+ * - initializer: 48-byte data to send in our REQUEST_ACCESS
236
+ * - localKey: 16-byte AES key (this becomes our RECEIVE key)
237
+ */
238
+ export function generateType1Initializer(accessCode, integrationId, method = 'ascii', verbose = false) {
239
+ const log = verbose ? console.log : () => {};
240
+ // Generate 32 random bytes
241
+ const randomData = randomBytes(32);
242
+
243
+ // Check bytes = even indexes of random data (sent in plaintext for verification)
244
+ const checkBytes = evenIndexes(randomData);
245
+
246
+ // Our local key = odd indexes of random data (this is our RECEIVE key)
247
+ const localKey = oddIndexes(randomData);
248
+
249
+ // Derive encryption key
250
+ let key;
251
+ let keySource = accessCode;
252
+ if (method === 'intid-hex-4x') {
253
+ keySource = integrationId;
254
+ const intId8 = integrationId.substring(0, 8);
255
+ key = Buffer.from(intId8 + intId8 + intId8 + intId8, 'hex');
256
+ } else {
257
+ key = transformType1KeyString(accessCode, method);
258
+ }
259
+
260
+ log(`[Type1Gen] Key source: ${keySource}`);
261
+ log(`[Type1Gen] Key method: ${method}`);
262
+ log(`[Type1Gen] Derived key: ${key.toString('hex')}`);
263
+ log(`[Type1Gen] Random data: ${randomData.toString('hex')}`);
264
+ log(`[Type1Gen] Check bytes: ${checkBytes.toString('hex')}`);
265
+ log(`[Type1Gen] Local key: ${localKey.toString('hex')}`);
266
+
267
+ // Encrypt the random data
268
+ const cipherText = aesEcbEncrypt(key, randomData);
269
+ log(`[Type1Gen] Ciphertext: ${cipherText.toString('hex')}`);
270
+
271
+ // Self-verification: decrypt and verify check bytes match
272
+ const decrypted = aesEcbDecrypt(key, cipherText);
273
+ const verifyEven = evenIndexes(decrypted);
274
+ const verifyOdd = oddIndexes(decrypted);
275
+ log(`[Type1Gen] Self-verify decrypted: ${decrypted.toString('hex')}`);
276
+ log(`[Type1Gen] Self-verify even idx: ${verifyEven.toString('hex')}`);
277
+ log(`[Type1Gen] Self-verify matches: ${checkBytes.equals(verifyEven) ? 'YES ✓' : 'NO ✗'}`);
278
+
279
+ // Build 48-byte initializer: [16 check bytes][32 ciphertext]
280
+ const initializer = Buffer.concat([checkBytes, cipherText]);
281
+
282
+ return { initializer, localKey };
283
+ }
284
+
285
+ /**
286
+ * Type 2 initializer transform (symmetric)
287
+ * Same transform for encoding local and decoding remote initializers
288
+ *
289
+ * @param {string} integrationAccessCode - 32-digit access code from [700]
290
+ * @param {Buffer} initializer - 16-byte initializer
291
+ * @returns {Buffer} 16-byte transformed result
292
+ */
293
+ export function type2InitializerTransform(integrationAccessCode, initializer) {
294
+ if (integrationAccessCode.length !== 32) {
295
+ throw new Error(`Type 2 access code must be 32 digits, got ${integrationAccessCode.length}`);
296
+ }
297
+ if (initializer.length !== 16) {
298
+ throw new Error(`Type 2 initializer must be 16 bytes, got ${initializer.length}`);
299
+ }
300
+
301
+ const key = Buffer.from(integrationAccessCode, 'hex');
302
+ return aesEcbEncrypt(key, initializer);
303
+ }
304
+
305
+ /**
306
+ * Generate random 16-byte key
307
+ */
308
+ export function getRandomKey() {
309
+ return randomBytes(16);
310
+ }