@faizahmed/secret-keystore 1.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.
@@ -0,0 +1,678 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - Runtime Keystore
3
+ *
4
+ * Secure in-memory storage for decrypted secrets with:
5
+ * - Multiple source types (env, json, yaml, object, values)
6
+ * - TTL with auto-refresh
7
+ * - In-memory encryption
8
+ * - Comprehensive access tracking
9
+ */
10
+
11
+ const crypto = require('node:crypto');
12
+ const { decryptKMSValues } = require('./kms');
13
+ const { decryptKMSObject } = require('./object-operations');
14
+ const {
15
+ decryptKMSEnvContent,
16
+ decryptKMSJsonContent,
17
+ decryptKMSYamlContent
18
+ } = require('./content-operations');
19
+ const { buildKeystoreOptions, validateKmsKeyId } = require('./options');
20
+ const { KeystoreError, SecretNotFoundError, KEYSTORE_ERROR_CODES } = require('./errors');
21
+
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+ // SECURE MEMORY UTILITIES
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+
26
+ /**
27
+ * Securely wipe a buffer or string from memory
28
+ */
29
+ function secureWipe(data) {
30
+ if (Buffer.isBuffer(data)) {
31
+ crypto.randomFillSync(data);
32
+ } else if (typeof data === 'string') {
33
+ // Can't truly wipe strings in JS, but we can help GC
34
+ return '';
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+ // SECRET KEYSTORE V2
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+
43
+ class SecretKeyStore {
44
+ #store = new Map();
45
+ #metadata = new Map();
46
+ #encryptionKey;
47
+ #initialized = false;
48
+ #destroyed = false;
49
+ #options;
50
+ #kmsKeyId;
51
+ #source;
52
+ #initPromise = null;
53
+ #refreshTimers = new Map();
54
+
55
+ /**
56
+ * Create a new SecretKeyStore
57
+ *
58
+ * @param {Object} source - Source configuration
59
+ * @param {string} source.type - 'env' | 'json' | 'yaml' | 'object' | 'values'
60
+ * @param {string} [source.content] - Content string (for env, json, yaml)
61
+ * @param {Object} [source.object] - Object (for object type)
62
+ * @param {Object} [source.values] - Key-value pairs (for values type)
63
+ * @param {string} kmsKeyId - KMS key ID (required)
64
+ * @param {Object} [options] - Keystore options
65
+ */
66
+ constructor(source, kmsKeyId, options = {}) {
67
+ validateKmsKeyId(kmsKeyId);
68
+
69
+ this.#source = source;
70
+ this.#kmsKeyId = kmsKeyId;
71
+ this.#options = buildKeystoreOptions(options);
72
+ this.#encryptionKey = crypto.randomBytes(32);
73
+ }
74
+
75
+ /**
76
+ * Initialize the keystore by decrypting all secrets
77
+ */
78
+ async initialize() {
79
+ if (this.#destroyed) {
80
+ throw new KeystoreError(
81
+ 'Cannot initialize destroyed keystore',
82
+ KEYSTORE_ERROR_CODES.DESTROYED
83
+ );
84
+ }
85
+
86
+ if (this.#initPromise) {
87
+ return this.#initPromise;
88
+ }
89
+
90
+ if (this.#initialized) {
91
+ return;
92
+ }
93
+
94
+ this.#initPromise = this.#doInitialize();
95
+ return this.#initPromise;
96
+ }
97
+
98
+ async #doInitialize() {
99
+ const logger = this.#options.logger;
100
+ const startTime = Date.now();
101
+
102
+ try {
103
+ logger?.info?.('[SecretKeyStore] Initializing...');
104
+
105
+ let decryptedValues;
106
+
107
+ switch (this.#source.type) {
108
+ case 'env':
109
+ decryptedValues = await this.#initFromEnv();
110
+ break;
111
+ case 'json':
112
+ decryptedValues = await this.#initFromJson();
113
+ break;
114
+ case 'yaml':
115
+ decryptedValues = await this.#initFromYaml();
116
+ break;
117
+ case 'object':
118
+ decryptedValues = await this.#initFromObject();
119
+ break;
120
+ case 'values':
121
+ decryptedValues = await this.#initFromValues();
122
+ break;
123
+ default:
124
+ throw new KeystoreError(
125
+ `Unknown source type: ${this.#source.type}`,
126
+ KEYSTORE_ERROR_CODES.INITIALIZATION_FAILED
127
+ );
128
+ }
129
+
130
+ // Store decrypted values
131
+ for (const [key, value] of Object.entries(decryptedValues)) {
132
+ this.#storeSecret(key, value);
133
+ }
134
+
135
+ // Validate no process.env leak
136
+ if (this.#options.validation?.noProcessEnvLeak) {
137
+ this.#validateNoProcessEnvLeak();
138
+ }
139
+
140
+ this.#initialized = true;
141
+ const duration = Date.now() - startTime;
142
+ logger?.info?.(
143
+ `[SecretKeyStore] Initialized ${this.#store.size} secrets in ${duration}ms`
144
+ );
145
+ } catch (error) {
146
+ this.#initPromise = null;
147
+ throw new KeystoreError(
148
+ `Initialization failed: ${error.message}`,
149
+ KEYSTORE_ERROR_CODES.INITIALIZATION_FAILED,
150
+ error
151
+ );
152
+ }
153
+ }
154
+
155
+ async #initFromEnv() {
156
+ const result = await decryptKMSEnvContent(
157
+ this.#source.content,
158
+ this.#kmsKeyId,
159
+ this.#buildDecryptOptions()
160
+ );
161
+
162
+ // Parse the decrypted content to extract values
163
+ const { parseEnvContent } = require('./content-operations');
164
+ const parsed = parseEnvContent(result.content);
165
+ const values = {};
166
+
167
+ for (const entry of parsed) {
168
+ if (entry.type === 'keyvalue') {
169
+ values[entry.key] = entry.value;
170
+ }
171
+ }
172
+
173
+ return values;
174
+ }
175
+
176
+ async #initFromJson() {
177
+ const result = await decryptKMSJsonContent(
178
+ this.#source.content,
179
+ this.#kmsKeyId,
180
+ this.#buildDecryptOptions()
181
+ );
182
+
183
+ // Flatten the decrypted object
184
+ const obj = structuredClone(JSON.parse(result.content));
185
+ return this.#flattenObject(obj);
186
+ }
187
+
188
+ async #initFromYaml() {
189
+ const result = await decryptKMSYamlContent(
190
+ this.#source.content,
191
+ this.#kmsKeyId,
192
+ this.#buildDecryptOptions()
193
+ );
194
+
195
+ // Parse and flatten - parseYaml handles js-yaml availability
196
+ const { parseYaml } = require('./yaml-utils');
197
+ const obj = parseYaml(result.content);
198
+
199
+ return this.#flattenObject(obj);
200
+ }
201
+
202
+ async #initFromObject() {
203
+ const result = await decryptKMSObject(
204
+ this.#source.object,
205
+ this.#kmsKeyId,
206
+ this.#buildDecryptOptions()
207
+ );
208
+
209
+ return this.#flattenObject(result.object);
210
+ }
211
+
212
+ async #initFromValues() {
213
+ const result = await decryptKMSValues(
214
+ this.#source.values,
215
+ this.#kmsKeyId,
216
+ this.#buildDecryptOptions()
217
+ );
218
+
219
+ return result.values;
220
+ }
221
+
222
+ #buildDecryptOptions() {
223
+ return {
224
+ aws: this.#options.aws,
225
+ attestation: this.#options.attestation,
226
+ logger: this.#options.logger,
227
+ logLevel: this.#options.logLevel,
228
+ paths: this.#options.paths,
229
+ patterns: this.#options.patterns,
230
+ exclude: this.#options.exclude,
231
+ skip: this.#options.skip,
232
+ continueOnError: this.#options.continueOnError
233
+ };
234
+ }
235
+
236
+ #flattenObject(obj, prefix = '') {
237
+ const result = {};
238
+
239
+ for (const [key, value] of Object.entries(obj || {})) {
240
+ const path = prefix ? `${prefix}.${key}` : key;
241
+
242
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
243
+ Object.assign(result, this.#flattenObject(value, path));
244
+ } else if (typeof value === 'string') {
245
+ result[path] = value;
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
251
+
252
+ #storeSecret(key, plaintext) {
253
+ const now = Date.now();
254
+
255
+ if (this.#options.security?.inMemoryEncryption) {
256
+ const iv = crypto.randomBytes(16);
257
+ const cipher = crypto.createCipheriv('aes-256-gcm', this.#encryptionKey, iv);
258
+
259
+ let encrypted = cipher.update(plaintext, 'utf-8', 'hex');
260
+ encrypted += cipher.final('hex');
261
+ const authTag = cipher.getAuthTag();
262
+
263
+ this.#store.set(key, {
264
+ encrypted,
265
+ iv: iv.toString('hex'),
266
+ authTag: authTag.toString('hex')
267
+ });
268
+ } else {
269
+ this.#store.set(key, { plaintext });
270
+ }
271
+
272
+ this.#metadata.set(key, {
273
+ createdAt: now,
274
+ lastAccessedAt: null,
275
+ accessCount: 0,
276
+ expiresAt: this.#options.access?.ttl ? now + this.#options.access.ttl : null
277
+ });
278
+
279
+ // Set up auto-refresh timer if TTL and autoRefresh enabled
280
+ if (this.#options.access?.ttl && this.#options.access?.autoRefresh) {
281
+ this.#setupAutoRefresh(key);
282
+ }
283
+ }
284
+
285
+ #setupAutoRefresh(key) {
286
+ const ttl = this.#options.access?.ttl;
287
+ if (!ttl) return;
288
+
289
+ // Clear existing timer
290
+ if (this.#refreshTimers.has(key)) {
291
+ clearTimeout(this.#refreshTimers.get(key));
292
+ }
293
+
294
+ // Set timer to refresh before expiry
295
+ const refreshTime = Math.max(ttl - 60000, ttl * 0.9); // 1 min before or 90%
296
+ const timer = setTimeout(async () => {
297
+ try {
298
+ await this.#refreshKey(key);
299
+ } catch (error) {
300
+ this.#options.logger?.error?.(
301
+ `[SecretKeyStore] Auto-refresh failed for ${key}: ${error.message}`
302
+ );
303
+ }
304
+ }, refreshTime);
305
+
306
+ // Don't block process exit
307
+ timer.unref?.();
308
+ this.#refreshTimers.set(key, timer);
309
+ }
310
+
311
+ async #refreshKey(key) {
312
+ if (this.#destroyed) return;
313
+
314
+ const logger = this.#options.logger;
315
+ logger?.debug?.(`[SecretKeyStore] Refreshing: ${key}`);
316
+
317
+ // Re-decrypt the original source for this key
318
+ // This is a simplified version - in production you'd want to track original encrypted values
319
+ const meta = this.#metadata.get(key);
320
+ if (meta) {
321
+ meta.expiresAt = this.#options.access?.ttl
322
+ ? Date.now() + this.#options.access.ttl
323
+ : null;
324
+ this.#setupAutoRefresh(key);
325
+ }
326
+ }
327
+
328
+ #retrieveSecret(key) {
329
+ const stored = this.#store.get(key);
330
+ if (!stored) return null;
331
+
332
+ // Update access metadata
333
+ const meta = this.#metadata.get(key);
334
+ if (meta) {
335
+ meta.lastAccessedAt = Date.now();
336
+ meta.accessCount++;
337
+ }
338
+
339
+ if (this.#options.security?.inMemoryEncryption) {
340
+ const decipher = crypto.createDecipheriv(
341
+ 'aes-256-gcm',
342
+ this.#encryptionKey,
343
+ Buffer.from(stored.iv, 'hex')
344
+ );
345
+ decipher.setAuthTag(Buffer.from(stored.authTag, 'hex'));
346
+
347
+ let decrypted = decipher.update(stored.encrypted, 'hex', 'utf-8');
348
+ decrypted += decipher.final('utf-8');
349
+ return decrypted;
350
+ }
351
+
352
+ return stored.plaintext;
353
+ }
354
+
355
+ #checkExpiry(key) {
356
+ const meta = this.#metadata.get(key);
357
+ if (!meta?.expiresAt) return false;
358
+
359
+ if (Date.now() > meta.expiresAt) {
360
+ if (this.#options.access?.autoRefresh) {
361
+ // Will be refreshed on next access
362
+ return false;
363
+ }
364
+ throw new KeystoreError(`Secret expired: ${key}`, KEYSTORE_ERROR_CODES.SECRET_EXPIRED);
365
+ }
366
+
367
+ return false;
368
+ }
369
+
370
+ #checkAccessLimit(key) {
371
+ const limit = this.#options.access?.accessLimit;
372
+ if (!limit) return;
373
+
374
+ const meta = this.#metadata.get(key);
375
+ if (meta && meta.accessCount >= limit) {
376
+ throw new KeystoreError(
377
+ `Access limit exceeded for: ${key}`,
378
+ KEYSTORE_ERROR_CODES.ACCESS_LIMIT_EXCEEDED
379
+ );
380
+ }
381
+ }
382
+
383
+ #validateNoProcessEnvLeak() {
384
+ for (const key of this.#store.keys()) {
385
+ const decrypted = this.#retrieveSecret(key);
386
+ const envValue = process.env[key];
387
+
388
+ if (envValue && envValue === decrypted) {
389
+ this.#options.logger?.error?.(
390
+ `[SECURITY] ${key} has decrypted value in process.env!`
391
+ );
392
+ }
393
+ }
394
+ }
395
+
396
+ #ensureInitialized() {
397
+ if (this.#destroyed) {
398
+ throw new KeystoreError('Keystore has been destroyed', KEYSTORE_ERROR_CODES.DESTROYED);
399
+ }
400
+ if (!this.#initialized) {
401
+ throw new KeystoreError(
402
+ 'Keystore not initialized. Call initialize() first.',
403
+ KEYSTORE_ERROR_CODES.NOT_INITIALIZED
404
+ );
405
+ }
406
+ }
407
+
408
+ // =========================================================================
409
+ // PUBLIC API
410
+ // =========================================================================
411
+
412
+ /**
413
+ * Get a secret by key
414
+ * @param {string} key - Secret key
415
+ * @returns {string|undefined}
416
+ */
417
+ get(key) {
418
+ this.#ensureInitialized();
419
+
420
+ if (!this.#store.has(key)) {
421
+ if (this.#options.validation?.throwOnMissingKey) {
422
+ throw new SecretNotFoundError(key);
423
+ }
424
+ return undefined;
425
+ }
426
+
427
+ this.#checkExpiry(key);
428
+ this.#checkAccessLimit(key);
429
+
430
+ const value = this.#retrieveSecret(key);
431
+
432
+ // Clear on access if configured
433
+ if (this.#options.access?.clearOnAccess) {
434
+ this.clearKey(key);
435
+ }
436
+
437
+ return value;
438
+ }
439
+
440
+ /**
441
+ * Get a section of secrets by path prefix
442
+ * @param {string} prefix - Path prefix
443
+ * @returns {Object|undefined}
444
+ */
445
+ getSection(prefix) {
446
+ this.#ensureInitialized();
447
+
448
+ const result = {};
449
+ let found = false;
450
+
451
+ for (const key of this.#store.keys()) {
452
+ if (key === prefix || key.startsWith(prefix + '.')) {
453
+ const value = this.get(key);
454
+ if (value !== undefined) {
455
+ result[key] = value;
456
+ found = true;
457
+ }
458
+ }
459
+ }
460
+
461
+ return found ? result : undefined;
462
+ }
463
+
464
+ /**
465
+ * Get all secrets
466
+ * @returns {Object}
467
+ */
468
+ getAll() {
469
+ this.#ensureInitialized();
470
+
471
+ const result = {};
472
+ for (const key of this.#store.keys()) {
473
+ result[key] = this.get(key);
474
+ }
475
+ return result;
476
+ }
477
+
478
+ /**
479
+ * Check if a secret exists
480
+ * @param {string} key - Secret key
481
+ * @returns {boolean}
482
+ */
483
+ has(key) {
484
+ if (!this.#initialized || this.#destroyed) {
485
+ return false;
486
+ }
487
+ return this.#store.has(key);
488
+ }
489
+
490
+ /**
491
+ * Get all secret keys
492
+ * @returns {string[]}
493
+ */
494
+ keys() {
495
+ if (!this.#initialized) {
496
+ return [];
497
+ }
498
+ return Array.from(this.#store.keys());
499
+ }
500
+
501
+ /**
502
+ * Check if keystore is initialized
503
+ * @returns {boolean}
504
+ */
505
+ isInitialized() {
506
+ return this.#initialized && !this.#destroyed;
507
+ }
508
+
509
+ /**
510
+ * Get keystore metadata
511
+ * @returns {Object}
512
+ */
513
+ getMetadata() {
514
+ return {
515
+ initialized: this.#initialized,
516
+ destroyed: this.#destroyed,
517
+ secretCount: this.#store.size,
518
+ sourceType: this.#source.type,
519
+ hasTTL: !!this.#options.access?.ttl,
520
+ ttl: this.#options.access?.ttl || null,
521
+ autoRefresh: !!this.#options.access?.autoRefresh,
522
+ inMemoryEncryption: !!this.#options.security?.inMemoryEncryption
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Get access stats for a key
528
+ * @param {string} key - Secret key
529
+ * @returns {Object|null}
530
+ */
531
+ getAccessStats(key) {
532
+ const meta = this.#metadata.get(key);
533
+ if (!meta) return null;
534
+
535
+ return {
536
+ createdAt: new Date(meta.createdAt),
537
+ lastAccessedAt: meta.lastAccessedAt ? new Date(meta.lastAccessedAt) : null,
538
+ accessCount: meta.accessCount,
539
+ expiresAt: meta.expiresAt ? new Date(meta.expiresAt) : null,
540
+ isExpired: meta.expiresAt ? Date.now() > meta.expiresAt : false
541
+ };
542
+ }
543
+
544
+ /**
545
+ * Refresh all secrets (re-decrypt from source)
546
+ */
547
+ async refresh() {
548
+ this.#ensureInitialized();
549
+
550
+ this.#options.logger?.info?.('[SecretKeyStore] Refreshing all secrets...');
551
+
552
+ // Clear current store
553
+ this.#store.clear();
554
+ this.#metadata.clear();
555
+
556
+ // Clear refresh timers
557
+ for (const timer of this.#refreshTimers.values()) {
558
+ clearTimeout(timer);
559
+ }
560
+ this.#refreshTimers.clear();
561
+
562
+ // Re-initialize
563
+ this.#initialized = false;
564
+ this.#initPromise = null;
565
+ await this.initialize();
566
+ }
567
+
568
+ /**
569
+ * Clear all secrets from memory
570
+ */
571
+ clear() {
572
+ // Secure wipe if enabled
573
+ if (this.#options.security?.secureWipe) {
574
+ for (const stored of this.#store.values()) {
575
+ if (stored.plaintext) {
576
+ secureWipe(stored.plaintext);
577
+ }
578
+ }
579
+ }
580
+
581
+ this.#store.clear();
582
+ this.#metadata.clear();
583
+
584
+ for (const timer of this.#refreshTimers.values()) {
585
+ clearTimeout(timer);
586
+ }
587
+ this.#refreshTimers.clear();
588
+
589
+ this.#options.logger?.info?.('[SecretKeyStore] Cleared all secrets');
590
+ }
591
+
592
+ /**
593
+ * Clear a specific secret from memory
594
+ * @param {string} key - Secret key
595
+ */
596
+ clearKey(key) {
597
+ const stored = this.#store.get(key);
598
+ if (stored && this.#options.security?.secureWipe && stored.plaintext) {
599
+ secureWipe(stored.plaintext);
600
+ }
601
+
602
+ this.#store.delete(key);
603
+ this.#metadata.delete(key);
604
+
605
+ if (this.#refreshTimers.has(key)) {
606
+ clearTimeout(this.#refreshTimers.get(key));
607
+ this.#refreshTimers.delete(key);
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Destroy the keystore (cannot be used after this)
613
+ */
614
+ destroy() {
615
+ if (this.#destroyed) return;
616
+
617
+ this.clear();
618
+
619
+ // Wipe encryption key
620
+ if (this.#encryptionKey) {
621
+ crypto.randomFillSync(this.#encryptionKey);
622
+ this.#encryptionKey = null;
623
+ }
624
+
625
+ this.#destroyed = true;
626
+ this.#initialized = false;
627
+
628
+ this.#options.logger?.info?.('[SecretKeyStore] Destroyed');
629
+ }
630
+ }
631
+
632
+ // ═══════════════════════════════════════════════════════════════════════════
633
+ // FACTORY FUNCTION
634
+ // ═══════════════════════════════════════════════════════════════════════════
635
+
636
+ /**
637
+ * Create and initialize a SecretKeyStore
638
+ *
639
+ * @param {Object} source - Source configuration
640
+ * @param {string} source.type - 'env' | 'json' | 'yaml' | 'object' | 'values'
641
+ * @param {string} [source.content] - Content string (for env, json, yaml)
642
+ * @param {Object} [source.object] - Object (for object type)
643
+ * @param {Object} [source.values] - Key-value pairs (for values type)
644
+ * @param {string} kmsKeyId - KMS key ID (required)
645
+ * @param {Object} [options] - Keystore options
646
+ * @returns {Promise<SecretKeyStore>}
647
+ *
648
+ * @example
649
+ * // From YAML content
650
+ * const content = fs.readFileSync('./secrets.yaml', 'utf-8');
651
+ * const keyStore = await createSecretKeyStore(
652
+ * { type: 'yaml', content },
653
+ * kmsKeyId,
654
+ * { patterns: ['**.password'] }
655
+ * );
656
+ *
657
+ * @example
658
+ * // From key-value pairs
659
+ * const keyStore = await createSecretKeyStore(
660
+ * { type: 'values', values: process.env },
661
+ * kmsKeyId,
662
+ * { paths: ['DB_PASSWORD', 'API_KEY'] }
663
+ * );
664
+ */
665
+ async function createSecretKeyStore(source, kmsKeyId, options = {}) {
666
+ const keyStore = new SecretKeyStore(source, kmsKeyId, options);
667
+ await keyStore.initialize();
668
+ return keyStore;
669
+ }
670
+
671
+ // ═══════════════════════════════════════════════════════════════════════════
672
+ // EXPORTS
673
+ // ═══════════════════════════════════════════════════════════════════════════
674
+
675
+ module.exports = {
676
+ SecretKeyStore,
677
+ createSecretKeyStore
678
+ };