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/README.md +342 -0
- package/package.json +46 -0
- package/src/ITV2Client.js +502 -0
- package/src/constants.js +106 -0
- package/src/event-handler.js +323 -0
- package/src/examples/README.md +287 -0
- package/src/examples/arm-disarm-example.js +152 -0
- package/src/examples/basic-monitoring.js +82 -0
- package/src/examples/interactive-cli.js +1033 -0
- package/src/index.js +17 -0
- package/src/itv2-crypto.js +310 -0
- package/src/itv2-session.js +1069 -0
- package/src/response-parsers.js +185 -0
- package/src/utils.js +240 -0
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
|
+
}
|