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