@sentriflow/core 0.1.9 → 0.2.1
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 +95 -0
- package/package.json +7 -2
- package/src/constants.ts +4 -1
- package/src/engine/RuleExecutor.ts +7 -1
- package/src/grx2-loader/GRX2ExtendedLoader.ts +645 -0
- package/src/grx2-loader/MachineId.ts +51 -0
- package/src/grx2-loader/index.ts +47 -0
- package/src/grx2-loader/types.ts +277 -0
- package/src/helpers/arista/helpers.ts +165 -95
- package/src/helpers/aruba/helpers.ts +11 -5
- package/src/helpers/cisco/helpers.ts +16 -8
- package/src/helpers/common/helpers.ts +19 -13
- package/src/helpers/common/validation.ts +6 -6
- package/src/helpers/cumulus/helpers.ts +11 -7
- package/src/helpers/extreme/helpers.ts +8 -5
- package/src/helpers/fortinet/helpers.ts +16 -6
- package/src/helpers/huawei/helpers.ts +112 -61
- package/src/helpers/juniper/helpers.ts +36 -20
- package/src/helpers/mikrotik/helpers.ts +10 -3
- package/src/helpers/nokia/helpers.ts +71 -42
- package/src/helpers/paloalto/helpers.ts +51 -41
- package/src/helpers/vyos/helpers.ts +58 -31
- package/src/index.ts +3 -0
- package/src/ip/extractor.ts +151 -61
- package/src/ip/index.ts +3 -0
- package/src/ip/types.ts +51 -0
- package/src/pack-loader/PackLoader.ts +29 -4
- package/src/parser/SchemaAwareParser.ts +84 -0
- package/src/parser/vendors/cisco-ios.ts +19 -5
- package/src/parser/vendors/cisco-nxos.ts +10 -2
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GRX2 Extended Pack Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads and decrypts extended GRX2 packs (self-contained with embedded wrapped TMK).
|
|
5
|
+
* Uses Node.js built-in crypto module for all operations.
|
|
6
|
+
*
|
|
7
|
+
* Security:
|
|
8
|
+
* - AES-256-GCM encryption with authenticated encryption
|
|
9
|
+
* - PBKDF2 key derivation (100,000 iterations)
|
|
10
|
+
* - Constant-time comparisons for auth tags
|
|
11
|
+
* - Memory zeroing for sensitive data
|
|
12
|
+
*
|
|
13
|
+
* @module @sentriflow/core/grx2-loader/GRX2ExtendedLoader
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createDecipheriv,
|
|
18
|
+
createHash,
|
|
19
|
+
pbkdf2Sync,
|
|
20
|
+
timingSafeEqual,
|
|
21
|
+
} from 'node:crypto';
|
|
22
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { join, basename } from 'node:path';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import type { RulePack, IRule, RuleMetadata, RuleVendor } from '../types/IRule';
|
|
27
|
+
import { compileNativeCheckFunction } from '../pack-loader/PackLoader';
|
|
28
|
+
import {
|
|
29
|
+
type GRX2ExtendedHeader,
|
|
30
|
+
type WrappedTMK,
|
|
31
|
+
type SerializedWrappedTMK,
|
|
32
|
+
type EncryptedPackInfo,
|
|
33
|
+
type GRX2PackLoadResult,
|
|
34
|
+
EncryptedPackError,
|
|
35
|
+
GRX2_HEADER_SIZE,
|
|
36
|
+
GRX2_EXTENDED_VERSION,
|
|
37
|
+
GRX2_EXTENDED_FLAG,
|
|
38
|
+
GRX2_PORTABLE_FLAG,
|
|
39
|
+
GRX2_ALGORITHM_AES_256_GCM,
|
|
40
|
+
GRX2_KDF_PBKDF2,
|
|
41
|
+
} from './types';
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Constants
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
/** AES-256-GCM algorithm */
|
|
48
|
+
const AES_ALGORITHM = 'aes-256-gcm';
|
|
49
|
+
|
|
50
|
+
/** PBKDF2 iterations (NIST recommended) */
|
|
51
|
+
const PBKDF2_ITERATIONS = 100000;
|
|
52
|
+
|
|
53
|
+
/** AES key size (32 bytes = 256 bits) */
|
|
54
|
+
const AES_KEY_SIZE = 32;
|
|
55
|
+
|
|
56
|
+
/** GCM IV size (12 bytes) */
|
|
57
|
+
const GCM_IV_SIZE = 12;
|
|
58
|
+
|
|
59
|
+
/** GCM auth tag size (16 bytes) */
|
|
60
|
+
const GCM_AUTH_TAG_SIZE = 16;
|
|
61
|
+
|
|
62
|
+
/** Pack hash size (truncated SHA-256) */
|
|
63
|
+
const PACK_HASH_SIZE = 16;
|
|
64
|
+
|
|
65
|
+
/** GRX2 magic bytes */
|
|
66
|
+
const GRX2_MAGIC = Buffer.from('GRX2', 'ascii');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Serialized rule structure in GRX2 packs.
|
|
70
|
+
* Rules are stored with checkSource (function source code) instead of check function.
|
|
71
|
+
*/
|
|
72
|
+
interface SerializedRule {
|
|
73
|
+
id: string;
|
|
74
|
+
selector?: string;
|
|
75
|
+
vendor?: RuleVendor | RuleVendor[];
|
|
76
|
+
category?: string | string[];
|
|
77
|
+
metadata: RuleMetadata;
|
|
78
|
+
checkSource: string; // Serialized function source
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Serialized rule pack structure in GRX2 files.
|
|
83
|
+
*/
|
|
84
|
+
interface SerializedRulePack {
|
|
85
|
+
name: string;
|
|
86
|
+
version: string;
|
|
87
|
+
publisher: string;
|
|
88
|
+
description?: string;
|
|
89
|
+
license?: string;
|
|
90
|
+
homepage?: string;
|
|
91
|
+
priority: number;
|
|
92
|
+
rules: SerializedRule[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Minimum wrapped TMK block size */
|
|
96
|
+
const MIN_WRAPPED_TMK_SIZE = 64;
|
|
97
|
+
|
|
98
|
+
/** Maximum wrapped TMK block size */
|
|
99
|
+
const MAX_WRAPPED_TMK_SIZE = 1024;
|
|
100
|
+
|
|
101
|
+
// =============================================================================
|
|
102
|
+
// Crypto Utilities
|
|
103
|
+
// =============================================================================
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Derive LDK (License-Derived Key) from license key, random salt, and machine ID
|
|
107
|
+
*
|
|
108
|
+
* SECURITY: Uses proper random salt for PBKDF2, with machineId as additional binding.
|
|
109
|
+
* The salt MUST be cryptographically random (stored in wrapped TMK).
|
|
110
|
+
* MachineId provides device binding but is NOT the primary entropy source.
|
|
111
|
+
*
|
|
112
|
+
* Key derivation: PBKDF2-SHA256(licenseKey, salt || machineId, 100000 iterations)
|
|
113
|
+
*
|
|
114
|
+
* @param licenseKey - The license key (primary secret)
|
|
115
|
+
* @param ldkSalt - Random 32-byte salt (from wrapped TMK)
|
|
116
|
+
* @param machineId - Machine identifier (optional binding, can be empty for portable packs)
|
|
117
|
+
* @returns 32-byte derived key
|
|
118
|
+
*/
|
|
119
|
+
function deriveLDK(licenseKey: string, ldkSalt: Buffer, machineId: string): Buffer {
|
|
120
|
+
// Combine random salt with machineId for device binding
|
|
121
|
+
// The random salt provides entropy; machineId provides device-specific binding
|
|
122
|
+
const combinedSalt = Buffer.concat([
|
|
123
|
+
ldkSalt,
|
|
124
|
+
Buffer.from(machineId, 'utf-8'),
|
|
125
|
+
]);
|
|
126
|
+
return pbkdf2Sync(licenseKey, combinedSalt, PBKDF2_ITERATIONS, AES_KEY_SIZE, 'sha256');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decrypt data with AES-256-GCM
|
|
131
|
+
*
|
|
132
|
+
* @param ciphertext - Encrypted data
|
|
133
|
+
* @param key - 32-byte decryption key
|
|
134
|
+
* @param iv - 12-byte initialization vector
|
|
135
|
+
* @param authTag - 16-byte authentication tag
|
|
136
|
+
* @returns Decrypted plaintext
|
|
137
|
+
* @throws EncryptedPackError if decryption fails
|
|
138
|
+
*/
|
|
139
|
+
function decrypt(
|
|
140
|
+
ciphertext: Buffer,
|
|
141
|
+
key: Buffer,
|
|
142
|
+
iv: Buffer,
|
|
143
|
+
authTag: Buffer
|
|
144
|
+
): Buffer {
|
|
145
|
+
try {
|
|
146
|
+
const decipher = createDecipheriv(AES_ALGORITHM, key, iv);
|
|
147
|
+
decipher.setAuthTag(authTag);
|
|
148
|
+
|
|
149
|
+
const plaintext = Buffer.concat([
|
|
150
|
+
decipher.update(ciphertext),
|
|
151
|
+
decipher.final(),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
return plaintext;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new EncryptedPackError(
|
|
157
|
+
'Decryption failed (invalid key or corrupted data)',
|
|
158
|
+
'DECRYPTION_FAILED',
|
|
159
|
+
error
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compute truncated SHA-256 hash
|
|
166
|
+
*
|
|
167
|
+
* @param data - Data to hash
|
|
168
|
+
* @returns 16-byte truncated hash
|
|
169
|
+
*/
|
|
170
|
+
function packHash(data: Buffer): Buffer {
|
|
171
|
+
return createHash('sha256').update(data).digest().subarray(0, PACK_HASH_SIZE);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Zero out sensitive buffer
|
|
176
|
+
*
|
|
177
|
+
* @param buffer - Buffer to zero
|
|
178
|
+
*/
|
|
179
|
+
function zeroize(buffer: Buffer): void {
|
|
180
|
+
buffer.fill(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// =============================================================================
|
|
184
|
+
// GRX2 Parser
|
|
185
|
+
// =============================================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if buffer contains extended GRX2 format
|
|
189
|
+
*
|
|
190
|
+
* @param data - Buffer to check
|
|
191
|
+
* @returns true if extended format
|
|
192
|
+
*/
|
|
193
|
+
export function isExtendedGRX2(data: Buffer): boolean {
|
|
194
|
+
if (data.length < GRX2_HEADER_SIZE) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const magic = data.subarray(0, 4);
|
|
199
|
+
const version = data.readUInt8(4);
|
|
200
|
+
const reservedByte = data.readUInt8(94);
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
magic.equals(GRX2_MAGIC) &&
|
|
204
|
+
version === GRX2_EXTENDED_VERSION &&
|
|
205
|
+
(reservedByte & GRX2_EXTENDED_FLAG) !== 0
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse extended GRX2 header from buffer
|
|
211
|
+
*
|
|
212
|
+
* @param data - Pack data buffer
|
|
213
|
+
* @returns Parsed header with wrapped TMK
|
|
214
|
+
* @throws EncryptedPackError if format is invalid
|
|
215
|
+
*/
|
|
216
|
+
function parseExtendedHeader(data: Buffer): {
|
|
217
|
+
header: GRX2ExtendedHeader;
|
|
218
|
+
payloadOffset: number;
|
|
219
|
+
} {
|
|
220
|
+
// Check minimum size
|
|
221
|
+
if (data.length < GRX2_HEADER_SIZE + 4) {
|
|
222
|
+
throw new EncryptedPackError(
|
|
223
|
+
'Pack too small for extended GRX2 format',
|
|
224
|
+
'PACK_CORRUPTED'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check format
|
|
229
|
+
if (!isExtendedGRX2(data)) {
|
|
230
|
+
throw new EncryptedPackError(
|
|
231
|
+
'Not an extended GRX2 pack (wrong magic, version, or flag)',
|
|
232
|
+
'PACK_CORRUPTED'
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Parse base header fields
|
|
237
|
+
const magic = data.subarray(0, 4);
|
|
238
|
+
const version = data.readUInt8(4);
|
|
239
|
+
const algorithm = data.readUInt8(5);
|
|
240
|
+
const kdf = data.readUInt8(6);
|
|
241
|
+
const keyType = data.readUInt8(7);
|
|
242
|
+
const tierId = data.readUInt16BE(8);
|
|
243
|
+
const tmkVersion = data.readUInt32BE(10);
|
|
244
|
+
const iv = Buffer.from(data.subarray(14, 26));
|
|
245
|
+
const authTag = Buffer.from(data.subarray(26, 42));
|
|
246
|
+
const salt = Buffer.from(data.subarray(42, 74));
|
|
247
|
+
const payloadLength = data.readUInt32BE(74);
|
|
248
|
+
const packHashBytes = Buffer.from(data.subarray(78, 94));
|
|
249
|
+
const reserved = Buffer.from(data.subarray(94, 96));
|
|
250
|
+
|
|
251
|
+
// Validate algorithm
|
|
252
|
+
if (algorithm !== GRX2_ALGORITHM_AES_256_GCM) {
|
|
253
|
+
throw new EncryptedPackError(
|
|
254
|
+
`Unsupported encryption algorithm: ${algorithm}`,
|
|
255
|
+
'PACK_CORRUPTED'
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Validate KDF
|
|
260
|
+
if (kdf !== GRX2_KDF_PBKDF2) {
|
|
261
|
+
throw new EncryptedPackError(
|
|
262
|
+
`Unsupported KDF: ${kdf}`,
|
|
263
|
+
'PACK_CORRUPTED'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Read wrapped TMK block length
|
|
268
|
+
const wrappedTMKLength = data.readUInt32BE(GRX2_HEADER_SIZE);
|
|
269
|
+
|
|
270
|
+
if (wrappedTMKLength < MIN_WRAPPED_TMK_SIZE || wrappedTMKLength > MAX_WRAPPED_TMK_SIZE) {
|
|
271
|
+
throw new EncryptedPackError(
|
|
272
|
+
`Invalid wrapped TMK length: ${wrappedTMKLength}`,
|
|
273
|
+
'PACK_CORRUPTED'
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const totalHeaderSize = GRX2_HEADER_SIZE + 4 + wrappedTMKLength;
|
|
278
|
+
|
|
279
|
+
if (data.length < totalHeaderSize) {
|
|
280
|
+
throw new EncryptedPackError(
|
|
281
|
+
`Pack too small for wrapped TMK block`,
|
|
282
|
+
'PACK_CORRUPTED'
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Parse wrapped TMK JSON
|
|
287
|
+
const wrappedTMKBuffer = data.subarray(GRX2_HEADER_SIZE + 4, totalHeaderSize);
|
|
288
|
+
let serialized: SerializedWrappedTMK;
|
|
289
|
+
try {
|
|
290
|
+
serialized = JSON.parse(wrappedTMKBuffer.toString('utf8'));
|
|
291
|
+
} catch {
|
|
292
|
+
throw new EncryptedPackError(
|
|
293
|
+
'Failed to parse wrapped TMK block',
|
|
294
|
+
'PACK_CORRUPTED'
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Validate and deserialize
|
|
299
|
+
if (!serialized.k || !serialized.i || !serialized.t || typeof serialized.v !== 'number' || !serialized.s) {
|
|
300
|
+
throw new EncryptedPackError(
|
|
301
|
+
'Invalid wrapped TMK structure (missing required fields)',
|
|
302
|
+
'PACK_CORRUPTED'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const wrappedTMK: WrappedTMK = {
|
|
307
|
+
encryptedKey: Buffer.from(serialized.k, 'base64'),
|
|
308
|
+
iv: Buffer.from(serialized.i, 'base64'),
|
|
309
|
+
authTag: Buffer.from(serialized.t, 'base64'),
|
|
310
|
+
tmkVersion: serialized.v,
|
|
311
|
+
ldkSalt: Buffer.from(serialized.s, 'base64'),
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Check portable flag (bit 1 of reserved byte 94)
|
|
315
|
+
const reservedByte = data.readUInt8(94);
|
|
316
|
+
const isPortable = (reservedByte & GRX2_PORTABLE_FLAG) !== 0;
|
|
317
|
+
|
|
318
|
+
const header: GRX2ExtendedHeader = {
|
|
319
|
+
magic: Buffer.from(magic),
|
|
320
|
+
version,
|
|
321
|
+
algorithm,
|
|
322
|
+
kdf,
|
|
323
|
+
keyType,
|
|
324
|
+
tierId,
|
|
325
|
+
tmkVersion,
|
|
326
|
+
iv,
|
|
327
|
+
authTag,
|
|
328
|
+
salt,
|
|
329
|
+
payloadLength,
|
|
330
|
+
packHash: packHashBytes,
|
|
331
|
+
reserved,
|
|
332
|
+
isExtended: true,
|
|
333
|
+
isPortable,
|
|
334
|
+
wrappedTMK,
|
|
335
|
+
totalHeaderSize,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return { header, payloadOffset: totalHeaderSize };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// =============================================================================
|
|
342
|
+
// Pack Loader
|
|
343
|
+
// =============================================================================
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Load and decrypt a single extended GRX2 pack
|
|
347
|
+
*
|
|
348
|
+
* @param filePath - Path to the .grx2 file
|
|
349
|
+
* @param licenseKey - License key for decryption
|
|
350
|
+
* @param machineId - Machine ID for LDK derivation (empty string for portable packs)
|
|
351
|
+
* @param debug - Optional debug callback for logging
|
|
352
|
+
* @returns Loaded rule pack
|
|
353
|
+
* @throws EncryptedPackError if loading fails
|
|
354
|
+
*/
|
|
355
|
+
export async function loadExtendedPack(
|
|
356
|
+
filePath: string,
|
|
357
|
+
licenseKey: string,
|
|
358
|
+
machineId: string,
|
|
359
|
+
debug?: (msg: string) => void
|
|
360
|
+
): Promise<RulePack> {
|
|
361
|
+
debug?.(`[GRX2Loader] Loading pack: ${filePath}`);
|
|
362
|
+
debug?.(`[GRX2Loader] License key length: ${licenseKey.length}, first 20 chars: ${licenseKey.substring(0, 20)}...`);
|
|
363
|
+
debug?.(`[GRX2Loader] Machine ID: "${machineId}" (length: ${machineId.length})`);
|
|
364
|
+
|
|
365
|
+
// Read pack file
|
|
366
|
+
const data = await readFile(filePath);
|
|
367
|
+
debug?.(`[GRX2Loader] Pack file size: ${data.length} bytes`);
|
|
368
|
+
|
|
369
|
+
// Parse extended header
|
|
370
|
+
const { header, payloadOffset } = parseExtendedHeader(data);
|
|
371
|
+
debug?.(`[GRX2Loader] Header parsed - version: ${header.version}, keyType: ${header.keyType}, tmkVersion: ${header.tmkVersion}, isPortable: ${header.isPortable}`);
|
|
372
|
+
|
|
373
|
+
// For portable packs, use empty machineId regardless of what was passed
|
|
374
|
+
// This allows portable packs to work on any machine
|
|
375
|
+
const effectiveMachineId = header.isPortable ? '' : machineId;
|
|
376
|
+
if (header.isPortable) {
|
|
377
|
+
debug?.(`[GRX2Loader] Portable pack detected - ignoring machineId for decryption`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Derive LDK from license key, random salt, and machine ID
|
|
381
|
+
// SECURITY: Salt is cryptographically random (stored in wrapped TMK)
|
|
382
|
+
// MachineId provides device binding but is NOT the primary entropy source
|
|
383
|
+
const ldk = deriveLDK(licenseKey, header.wrappedTMK.ldkSalt, effectiveMachineId);
|
|
384
|
+
debug?.(`[GRX2Loader] LDK derived successfully`);
|
|
385
|
+
|
|
386
|
+
// Unwrap TMK using LDK
|
|
387
|
+
let tmk: Buffer;
|
|
388
|
+
try {
|
|
389
|
+
tmk = decrypt(
|
|
390
|
+
header.wrappedTMK.encryptedKey,
|
|
391
|
+
ldk,
|
|
392
|
+
header.wrappedTMK.iv,
|
|
393
|
+
header.wrappedTMK.authTag
|
|
394
|
+
);
|
|
395
|
+
debug?.(`[GRX2Loader] TMK unwrapped successfully`);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
debug?.(`[GRX2Loader] TMK unwrap FAILED: ${(error as Error).message}`);
|
|
398
|
+
zeroize(ldk);
|
|
399
|
+
throw new EncryptedPackError(
|
|
400
|
+
'Failed to unwrap TMK - invalid license key or machine ID mismatch',
|
|
401
|
+
'DECRYPTION_FAILED',
|
|
402
|
+
error
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Zero out LDK (no longer needed)
|
|
407
|
+
zeroize(ldk);
|
|
408
|
+
|
|
409
|
+
// Extract encrypted payload
|
|
410
|
+
const encryptedPayload = data.subarray(payloadOffset);
|
|
411
|
+
|
|
412
|
+
if (encryptedPayload.length !== header.payloadLength) {
|
|
413
|
+
zeroize(tmk);
|
|
414
|
+
throw new EncryptedPackError(
|
|
415
|
+
`Payload length mismatch: expected ${header.payloadLength}, got ${encryptedPayload.length}`,
|
|
416
|
+
'PACK_CORRUPTED'
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Decrypt payload using TMK
|
|
421
|
+
let plaintext: Buffer;
|
|
422
|
+
try {
|
|
423
|
+
plaintext = decrypt(encryptedPayload, tmk, header.iv, header.authTag);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
zeroize(tmk);
|
|
426
|
+
throw new EncryptedPackError(
|
|
427
|
+
'Failed to decrypt pack payload',
|
|
428
|
+
'DECRYPTION_FAILED',
|
|
429
|
+
error
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Zero out TMK (no longer needed)
|
|
434
|
+
zeroize(tmk);
|
|
435
|
+
|
|
436
|
+
// Verify pack hash
|
|
437
|
+
const computedHash = packHash(plaintext);
|
|
438
|
+
if (!timingSafeEqual(computedHash, header.packHash)) {
|
|
439
|
+
throw new EncryptedPackError(
|
|
440
|
+
'Pack integrity check failed (hash mismatch)',
|
|
441
|
+
'PACK_CORRUPTED'
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Parse JSON content (contains serialized rules with checkSource)
|
|
446
|
+
let serializedPack: SerializedRulePack;
|
|
447
|
+
try {
|
|
448
|
+
serializedPack = JSON.parse(plaintext.toString('utf8'));
|
|
449
|
+
} catch {
|
|
450
|
+
throw new EncryptedPackError(
|
|
451
|
+
'Failed to parse pack JSON content',
|
|
452
|
+
'PACK_CORRUPTED'
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Compile serialized check functions to native functions
|
|
457
|
+
// SECURITY: Same justification as in PackLoader.ts - the code is from
|
|
458
|
+
// authenticated encrypted pack, verified by GCM auth tag
|
|
459
|
+
const compiledRules: IRule[] = serializedPack.rules.map((ruleDef) => {
|
|
460
|
+
const checkFn = compileNativeCheckFunction(ruleDef.checkSource);
|
|
461
|
+
return {
|
|
462
|
+
id: ruleDef.id,
|
|
463
|
+
selector: ruleDef.selector,
|
|
464
|
+
vendor: ruleDef.vendor,
|
|
465
|
+
category: ruleDef.category,
|
|
466
|
+
metadata: ruleDef.metadata,
|
|
467
|
+
check: checkFn,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const pack: RulePack = {
|
|
472
|
+
name: serializedPack.name,
|
|
473
|
+
version: serializedPack.version,
|
|
474
|
+
publisher: serializedPack.publisher,
|
|
475
|
+
description: serializedPack.description,
|
|
476
|
+
license: serializedPack.license,
|
|
477
|
+
homepage: serializedPack.homepage,
|
|
478
|
+
priority: serializedPack.priority,
|
|
479
|
+
rules: compiledRules,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return pack;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Resolve path with ~ expansion
|
|
487
|
+
*
|
|
488
|
+
* @param path - Path that may contain ~
|
|
489
|
+
* @returns Resolved path
|
|
490
|
+
*/
|
|
491
|
+
function resolvePath(path: string): string {
|
|
492
|
+
if (path.startsWith('~/')) {
|
|
493
|
+
return join(homedir(), path.slice(2));
|
|
494
|
+
}
|
|
495
|
+
return path;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Scan directory for .grx2 files
|
|
500
|
+
*
|
|
501
|
+
* @param directory - Directory to scan
|
|
502
|
+
* @param debug - Optional debug callback for logging
|
|
503
|
+
* @returns Array of .grx2 file paths
|
|
504
|
+
*/
|
|
505
|
+
async function scanForPacks(
|
|
506
|
+
directory: string,
|
|
507
|
+
debug?: (msg: string) => void
|
|
508
|
+
): Promise<string[]> {
|
|
509
|
+
const resolvedDir = resolvePath(directory);
|
|
510
|
+
debug?.(`[GRX2Loader] Scanning directory: ${directory} -> resolved: ${resolvedDir}`);
|
|
511
|
+
|
|
512
|
+
if (!existsSync(resolvedDir)) {
|
|
513
|
+
debug?.(`[GRX2Loader] Directory does not exist: ${resolvedDir}`);
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const entries = await readdir(resolvedDir);
|
|
518
|
+
debug?.(`[GRX2Loader] Found ${entries.length} entries in directory`);
|
|
519
|
+
|
|
520
|
+
const grx2Files = entries
|
|
521
|
+
.filter((entry) => entry.endsWith('.grx2'))
|
|
522
|
+
.map((entry) => join(resolvedDir, entry));
|
|
523
|
+
|
|
524
|
+
debug?.(`[GRX2Loader] Found ${grx2Files.length} .grx2 files: ${grx2Files.join(', ')}`);
|
|
525
|
+
|
|
526
|
+
return grx2Files;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Load all extended packs from a directory
|
|
531
|
+
*
|
|
532
|
+
* @param directory - Directory containing .grx2 files
|
|
533
|
+
* @param licenseKey - License key for decryption
|
|
534
|
+
* @param machineId - Machine ID for LDK derivation
|
|
535
|
+
* @param entitledFeeds - Optional list of entitled feed IDs (filter)
|
|
536
|
+
* @param debug - Optional debug callback for logging
|
|
537
|
+
* @returns Load result with packs and errors
|
|
538
|
+
*/
|
|
539
|
+
export async function loadAllPacks(
|
|
540
|
+
directory: string,
|
|
541
|
+
licenseKey: string,
|
|
542
|
+
machineId: string,
|
|
543
|
+
entitledFeeds?: string[],
|
|
544
|
+
debug?: (msg: string) => void
|
|
545
|
+
): Promise<GRX2PackLoadResult> {
|
|
546
|
+
const packs: EncryptedPackInfo[] = [];
|
|
547
|
+
const errors: string[] = [];
|
|
548
|
+
let totalRules = 0;
|
|
549
|
+
|
|
550
|
+
// Scan for pack files
|
|
551
|
+
const packFiles = await scanForPacks(directory, debug);
|
|
552
|
+
|
|
553
|
+
if (packFiles.length === 0) {
|
|
554
|
+
return {
|
|
555
|
+
success: true,
|
|
556
|
+
packs: [],
|
|
557
|
+
totalRules: 0,
|
|
558
|
+
errors: [],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Load each pack
|
|
563
|
+
for (const filePath of packFiles) {
|
|
564
|
+
const fileName = basename(filePath, '.grx2');
|
|
565
|
+
|
|
566
|
+
// Check entitlement if filter provided
|
|
567
|
+
if (entitledFeeds && !entitledFeeds.includes(fileName)) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
const pack = await loadExtendedPack(filePath, licenseKey, machineId, debug);
|
|
573
|
+
|
|
574
|
+
packs.push({
|
|
575
|
+
feedId: fileName,
|
|
576
|
+
name: pack.name,
|
|
577
|
+
version: pack.version,
|
|
578
|
+
publisher: pack.publisher,
|
|
579
|
+
ruleCount: pack.rules.length,
|
|
580
|
+
filePath,
|
|
581
|
+
loaded: true,
|
|
582
|
+
source: 'local',
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
totalRules += pack.rules.length;
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
588
|
+
errors.push(`${fileName}: ${message}`);
|
|
589
|
+
|
|
590
|
+
packs.push({
|
|
591
|
+
feedId: fileName,
|
|
592
|
+
name: fileName,
|
|
593
|
+
version: 'unknown',
|
|
594
|
+
publisher: 'unknown',
|
|
595
|
+
ruleCount: 0,
|
|
596
|
+
filePath,
|
|
597
|
+
loaded: false,
|
|
598
|
+
error: message,
|
|
599
|
+
source: 'local',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
success: errors.length === 0,
|
|
606
|
+
packs,
|
|
607
|
+
totalRules,
|
|
608
|
+
errors,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Get pack info without fully decrypting
|
|
614
|
+
*
|
|
615
|
+
* Useful for displaying pack metadata in UI before license is entered.
|
|
616
|
+
* Only parses header, does not require license key.
|
|
617
|
+
*
|
|
618
|
+
* @param filePath - Path to .grx2 file
|
|
619
|
+
* @returns Basic pack info from header
|
|
620
|
+
*/
|
|
621
|
+
export async function getPackInfo(filePath: string): Promise<{
|
|
622
|
+
feedId: string;
|
|
623
|
+
tierId: number;
|
|
624
|
+
tmkVersion: number;
|
|
625
|
+
isExtended: boolean;
|
|
626
|
+
}> {
|
|
627
|
+
const data = await readFile(filePath);
|
|
628
|
+
|
|
629
|
+
if (!isExtendedGRX2(data)) {
|
|
630
|
+
throw new EncryptedPackError(
|
|
631
|
+
'Not an extended GRX2 pack',
|
|
632
|
+
'PACK_CORRUPTED'
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const tierId = data.readUInt16BE(8);
|
|
637
|
+
const tmkVersion = data.readUInt32BE(10);
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
feedId: basename(filePath, '.grx2'),
|
|
641
|
+
tierId,
|
|
642
|
+
tmkVersion,
|
|
643
|
+
isExtended: true,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine ID Module
|
|
3
|
+
*
|
|
4
|
+
* Provides cross-platform machine identification for license binding.
|
|
5
|
+
* Uses node-machine-id library which derives a stable machine ID from OS-native identifiers.
|
|
6
|
+
*
|
|
7
|
+
* Security Notes:
|
|
8
|
+
* - Machine ID is used as additional binding in key derivation, NOT as the primary salt
|
|
9
|
+
* - Random cryptographic salt is always used for PBKDF2 (stored in wrapped TMK)
|
|
10
|
+
* - Empty machine ID is allowed for portable packs (no device binding)
|
|
11
|
+
*
|
|
12
|
+
* @module @sentriflow/core/grx2-loader/MachineId
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { machineId, machineIdSync } from 'node-machine-id';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the machine identifier asynchronously
|
|
19
|
+
*
|
|
20
|
+
* Returns a unique machine ID derived from the operating system.
|
|
21
|
+
* The ID is stable across reboots but may change if the OS is reinstalled.
|
|
22
|
+
*
|
|
23
|
+
* @returns Promise resolving to machine ID string
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const mid = await getMachineId();
|
|
28
|
+
* console.log(`Machine ID: ${mid}`);
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export async function getMachineId(): Promise<string> {
|
|
32
|
+
return machineId();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the machine identifier synchronously
|
|
37
|
+
*
|
|
38
|
+
* Returns a unique machine ID derived from the operating system.
|
|
39
|
+
* Prefer async version when possible.
|
|
40
|
+
*
|
|
41
|
+
* @returns Machine ID string
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const mid = getMachineIdSync();
|
|
46
|
+
* console.log(`Machine ID: ${mid}`);
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function getMachineIdSync(): string {
|
|
50
|
+
return machineIdSync();
|
|
51
|
+
}
|