@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,494 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - Content-Based Operations
3
+ *
4
+ * Functions for encrypting/decrypting content strings (ENV, JSON, YAML).
5
+ * Preserves comments and formatting during transformation.
6
+ */
7
+
8
+ const { encryptKMSValue, decryptKMSValue, isAlreadyEncrypted } = require('./kms');
9
+ const { encryptKMSObject, decryptKMSObject } = require('./object-operations');
10
+ const { validateKmsKeyId, buildEncryptOptions, buildDecryptOptions } = require('./options');
11
+ const { ContentError, CONTENT_ERROR_CODES } = require('./errors');
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // ENV CONTENT OPERATIONS
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+
17
+ /**
18
+ * Extract value and inline comment from a quoted string
19
+ * @param {string} valueWithComment - The value portion after =
20
+ * @param {string} quoteChar - The quote character (" or ')
21
+ * @returns {{value: string, inlineComment: string|null}}
22
+ */
23
+ function parseQuotedValue(valueWithComment, quoteChar) {
24
+ const closeQuote = valueWithComment.indexOf(quoteChar, 1);
25
+ if (closeQuote === -1) {
26
+ return { value: valueWithComment.slice(1), inlineComment: null };
27
+ }
28
+
29
+ const value = valueWithComment.slice(1, closeQuote);
30
+ const afterQuote = valueWithComment.slice(closeQuote + 1).trim();
31
+ const inlineComment = afterQuote.startsWith('#') ? afterQuote : null;
32
+
33
+ return { value, inlineComment };
34
+ }
35
+
36
+ /**
37
+ * Extract value and inline comment from an unquoted string
38
+ * @param {string} valueWithComment - The value portion after =
39
+ * @returns {{value: string, inlineComment: string|null}}
40
+ */
41
+ function parseUnquotedValue(valueWithComment) {
42
+ const hashIndex = valueWithComment.indexOf('#');
43
+ if (hashIndex === -1) {
44
+ return { value: valueWithComment.trim(), inlineComment: null };
45
+ }
46
+ return {
47
+ value: valueWithComment.slice(0, hashIndex).trim(),
48
+ inlineComment: valueWithComment.slice(hashIndex)
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Parse .env content into structured entries
54
+ * @param {string} content - Raw .env content
55
+ * @returns {Array<Object>} Parsed entries
56
+ */
57
+ function parseEnvContent(content) {
58
+ const lines = content.split('\n');
59
+ const parsed = [];
60
+
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+
64
+ if (!trimmed) {
65
+ parsed.push({ type: 'empty', raw: line });
66
+ continue;
67
+ }
68
+
69
+ if (trimmed.startsWith('#')) {
70
+ parsed.push({ type: 'comment', raw: line });
71
+ continue;
72
+ }
73
+
74
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
75
+ if (!match) {
76
+ parsed.push({ type: 'other', raw: line });
77
+ continue;
78
+ }
79
+
80
+ const key = match[1].trim();
81
+ const valueWithComment = match[2];
82
+
83
+ let result;
84
+ if (valueWithComment.startsWith('"')) {
85
+ result = parseQuotedValue(valueWithComment, '"');
86
+ } else if (valueWithComment.startsWith("'")) {
87
+ result = parseQuotedValue(valueWithComment, "'");
88
+ } else {
89
+ result = parseUnquotedValue(valueWithComment);
90
+ }
91
+
92
+ parsed.push({
93
+ type: 'keyvalue',
94
+ key,
95
+ value: result.value,
96
+ inlineComment: result.inlineComment,
97
+ raw: line
98
+ });
99
+ }
100
+
101
+ return parsed;
102
+ }
103
+
104
+ /**
105
+ * Reconstruct .env content from parsed entries
106
+ * @param {Array<Object>} parsed - Parsed entries
107
+ * @returns {string} Reconstructed content
108
+ */
109
+ function reconstructEnvContent(parsed) {
110
+ return parsed
111
+ .map(entry => {
112
+ if (entry.type === 'keyvalue') {
113
+ const needsQuotes =
114
+ entry.value.includes(' ') ||
115
+ entry.value.includes('#') ||
116
+ entry.value.includes('=') ||
117
+ entry.value.includes('\n');
118
+
119
+ let line = needsQuotes
120
+ ? `${entry.key}="${entry.value}"`
121
+ : `${entry.key}=${entry.value}`;
122
+
123
+ if (entry.inlineComment) {
124
+ line += ` ${entry.inlineComment}`;
125
+ }
126
+
127
+ return line;
128
+ }
129
+ return entry.raw;
130
+ })
131
+ .join('\n');
132
+ }
133
+
134
+ /**
135
+ * Encrypt .env content string using AWS KMS
136
+ *
137
+ * @param {string} content - Raw .env content
138
+ * @param {string} kmsKeyId - KMS key ID (required)
139
+ * @param {Object} [options] - Options
140
+ * @param {string[]} [options.paths] - Keys to encrypt (encrypt all if not provided)
141
+ * @param {Object} [options.exclude] - Keys to exclude
142
+ * @param {Object} [options.preserve] - Preservation options
143
+ * @returns {Promise<Object>} Result with encrypted content
144
+ */
145
+ async function encryptKMSEnvContent(content, kmsKeyId, options = {}) {
146
+ validateKmsKeyId(kmsKeyId);
147
+
148
+ const opts = buildEncryptOptions(options);
149
+ const logger = opts.logger;
150
+
151
+ if (!content || typeof content !== 'string') {
152
+ throw new ContentError(
153
+ 'Content must be a non-empty string',
154
+ CONTENT_ERROR_CODES.EMPTY_CONTENT,
155
+ 'env'
156
+ );
157
+ }
158
+
159
+ const parsed = parseEnvContent(content);
160
+ const keyValueEntries = parsed.filter(e => e.type === 'keyvalue');
161
+
162
+ // Determine which keys to encrypt
163
+ let keysToEncrypt;
164
+ if (options.paths && options.paths.length > 0) {
165
+ keysToEncrypt = options.paths;
166
+ } else {
167
+ keysToEncrypt = keyValueEntries.map(e => e.key);
168
+ }
169
+
170
+ // Apply exclusions
171
+ if (options.exclude?.paths) {
172
+ keysToEncrypt = keysToEncrypt.filter(k => !options.exclude.paths.includes(k));
173
+ }
174
+
175
+ const result = {
176
+ content: '',
177
+ encrypted: [],
178
+ skipped: [],
179
+ failed: []
180
+ };
181
+
182
+ const skipEmpty = opts.skip?.empty !== false;
183
+ const skipAlreadyEncrypted = opts.skip?.alreadyEncrypted !== false;
184
+ const continueOnError = opts.continueOnError === true;
185
+
186
+ for (const entry of keyValueEntries) {
187
+ if (!keysToEncrypt.includes(entry.key)) {
188
+ result.skipped.push(entry.key);
189
+ continue;
190
+ }
191
+
192
+ // Skip empty
193
+ if (skipEmpty && (!entry.value || entry.value.trim() === '')) {
194
+ result.skipped.push(entry.key);
195
+ continue;
196
+ }
197
+
198
+ // Skip already encrypted
199
+ if (skipAlreadyEncrypted && isAlreadyEncrypted(entry.value)) {
200
+ result.skipped.push(entry.key);
201
+ continue;
202
+ }
203
+
204
+ try {
205
+ entry.value = await encryptKMSValue(entry.value, kmsKeyId, opts);
206
+ result.encrypted.push(entry.key);
207
+ logger?.info?.(`[encryptKMSEnvContent] Encrypted: ${entry.key}`);
208
+ } catch (error) {
209
+ if (continueOnError) {
210
+ result.failed.push({ key: entry.key, error });
211
+ logger?.warn?.(`[encryptKMSEnvContent] Failed: ${entry.key}`);
212
+ } else {
213
+ throw error;
214
+ }
215
+ }
216
+ }
217
+
218
+ result.content = reconstructEnvContent(parsed);
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Decrypt .env content string using AWS KMS
224
+ *
225
+ * @param {string} content - Encrypted .env content
226
+ * @param {string} kmsKeyId - KMS key ID (required)
227
+ * @param {Object} [options] - Options
228
+ * @param {string[]} [options.paths] - Keys to decrypt (decrypt all if not provided)
229
+ * @param {Object} [options.attestation] - Attestation options
230
+ * @returns {Promise<Object>} Result with decrypted content
231
+ */
232
+ async function decryptKMSEnvContent(content, kmsKeyId, options = {}) {
233
+ validateKmsKeyId(kmsKeyId);
234
+
235
+ const opts = buildDecryptOptions(options);
236
+ const logger = opts.logger;
237
+
238
+ if (!content || typeof content !== 'string') {
239
+ throw new ContentError(
240
+ 'Content must be a non-empty string',
241
+ CONTENT_ERROR_CODES.EMPTY_CONTENT,
242
+ 'env'
243
+ );
244
+ }
245
+
246
+ const parsed = parseEnvContent(content);
247
+ const keyValueEntries = parsed.filter(e => e.type === 'keyvalue');
248
+
249
+ // Determine which keys to decrypt
250
+ let keysToDecrypt;
251
+ if (options.paths && options.paths.length > 0) {
252
+ keysToDecrypt = options.paths;
253
+ } else {
254
+ keysToDecrypt = keyValueEntries.map(e => e.key);
255
+ }
256
+
257
+ // Apply exclusions
258
+ if (options.exclude?.paths) {
259
+ keysToDecrypt = keysToDecrypt.filter(k => !options.exclude.paths.includes(k));
260
+ }
261
+
262
+ const result = {
263
+ content: '',
264
+ decrypted: [],
265
+ skipped: [],
266
+ failed: []
267
+ };
268
+
269
+ const skipUnencrypted = opts.skip?.unencrypted !== false;
270
+ const continueOnError = opts.continueOnError === true;
271
+
272
+ for (const entry of keyValueEntries) {
273
+ if (!keysToDecrypt.includes(entry.key)) {
274
+ result.skipped.push(entry.key);
275
+ continue;
276
+ }
277
+
278
+ // Skip unencrypted
279
+ if (skipUnencrypted && !isAlreadyEncrypted(entry.value)) {
280
+ result.skipped.push(entry.key);
281
+ continue;
282
+ }
283
+
284
+ try {
285
+ entry.value = await decryptKMSValue(entry.value, kmsKeyId, opts);
286
+ result.decrypted.push(entry.key);
287
+ logger?.info?.(`[decryptKMSEnvContent] Decrypted: ${entry.key}`);
288
+ } catch (error) {
289
+ if (continueOnError) {
290
+ result.failed.push({ key: entry.key, error });
291
+ logger?.warn?.(`[decryptKMSEnvContent] Failed: ${entry.key}`);
292
+ } else {
293
+ throw error;
294
+ }
295
+ }
296
+ }
297
+
298
+ result.content = reconstructEnvContent(parsed);
299
+ return result;
300
+ }
301
+
302
+ // ═══════════════════════════════════════════════════════════════════════════
303
+ // JSON CONTENT OPERATIONS
304
+ // ═══════════════════════════════════════════════════════════════════════════
305
+
306
+ /**
307
+ * Encrypt JSON content string using AWS KMS
308
+ *
309
+ * @param {string} content - JSON content string
310
+ * @param {string} kmsKeyId - KMS key ID (required)
311
+ * @param {Object} [options] - Options (same as encryptKMSObject)
312
+ * @returns {Promise<Object>} Result with encrypted JSON content
313
+ */
314
+ async function encryptKMSJsonContent(content, kmsKeyId, options = {}) {
315
+ validateKmsKeyId(kmsKeyId);
316
+
317
+ if (!content || typeof content !== 'string') {
318
+ throw new ContentError(
319
+ 'Content must be a non-empty string',
320
+ CONTENT_ERROR_CODES.EMPTY_CONTENT,
321
+ 'json'
322
+ );
323
+ }
324
+
325
+ let obj;
326
+ try {
327
+ obj = JSON.parse(content);
328
+ } catch (error) {
329
+ throw new ContentError(
330
+ `Failed to parse JSON content: ${error.message}`,
331
+ CONTENT_ERROR_CODES.PARSE_FAILED,
332
+ 'json',
333
+ error
334
+ );
335
+ }
336
+
337
+ const objectResult = await encryptKMSObject(obj, kmsKeyId, options);
338
+
339
+ return {
340
+ content: JSON.stringify(objectResult.object, null, 2),
341
+ encrypted: objectResult.encrypted,
342
+ skipped: objectResult.skipped,
343
+ failed: objectResult.failed
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Decrypt JSON content string using AWS KMS
349
+ *
350
+ * @param {string} content - Encrypted JSON content string
351
+ * @param {string} kmsKeyId - KMS key ID (required)
352
+ * @param {Object} [options] - Options (same as decryptKMSObject)
353
+ * @returns {Promise<Object>} Result with decrypted JSON content
354
+ */
355
+ async function decryptKMSJsonContent(content, kmsKeyId, options = {}) {
356
+ validateKmsKeyId(kmsKeyId);
357
+
358
+ if (!content || typeof content !== 'string') {
359
+ throw new ContentError(
360
+ 'Content must be a non-empty string',
361
+ CONTENT_ERROR_CODES.EMPTY_CONTENT,
362
+ 'json'
363
+ );
364
+ }
365
+
366
+ let obj;
367
+ try {
368
+ obj = JSON.parse(content);
369
+ } catch (error) {
370
+ throw new ContentError(
371
+ `Failed to parse JSON content: ${error.message}`,
372
+ CONTENT_ERROR_CODES.PARSE_FAILED,
373
+ 'json',
374
+ error
375
+ );
376
+ }
377
+
378
+ const objectResult = await decryptKMSObject(obj, kmsKeyId, options);
379
+
380
+ return {
381
+ content: JSON.stringify(objectResult.object, null, 2),
382
+ decrypted: objectResult.decrypted,
383
+ skipped: objectResult.skipped,
384
+ failed: objectResult.failed
385
+ };
386
+ }
387
+
388
+ // ═══════════════════════════════════════════════════════════════════════════
389
+ // YAML CONTENT OPERATIONS
390
+ // ═══════════════════════════════════════════════════════════════════════════
391
+
392
+ const { parseYaml, serializeYaml, isJsYamlAvailable } = require('./yaml-utils');
393
+
394
+ /**
395
+ * Encrypt YAML content string using AWS KMS
396
+ *
397
+ * Uses js-yaml if installed, otherwise falls back to simple parser for basic YAML.
398
+ * Complex YAML features (anchors, multi-line strings) require js-yaml.
399
+ *
400
+ * @param {string} content - YAML content string
401
+ * @param {string} kmsKeyId - KMS key ID (required)
402
+ * @param {Object} [options] - Options (same as encryptKMSObject)
403
+ * @returns {Promise<Object>} Result with encrypted YAML content
404
+ */
405
+ async function encryptKMSYamlContent(content, kmsKeyId, options = {}) {
406
+ validateKmsKeyId(kmsKeyId);
407
+
408
+ if (!content || typeof content !== 'string') {
409
+ throw new ContentError(
410
+ 'Content must be a non-empty string',
411
+ CONTENT_ERROR_CODES.EMPTY_CONTENT,
412
+ 'yaml'
413
+ );
414
+ }
415
+
416
+ // Log warning if js-yaml not available
417
+ const opts = buildEncryptOptions(options);
418
+ if (!isJsYamlAvailable()) {
419
+ opts.logger?.warn?.(
420
+ '[encryptKMSYamlContent] js-yaml not installed. Using simple parser (limited features).'
421
+ );
422
+ }
423
+
424
+ const obj = parseYaml(content);
425
+ const objectResult = await encryptKMSObject(obj, kmsKeyId, options);
426
+
427
+ return {
428
+ content: serializeYaml(objectResult.object),
429
+ encrypted: objectResult.encrypted,
430
+ skipped: objectResult.skipped,
431
+ failed: objectResult.failed
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Decrypt YAML content string using AWS KMS
437
+ *
438
+ * Uses js-yaml if installed, otherwise falls back to simple parser for basic YAML.
439
+ * Complex YAML features (anchors, multi-line strings) require js-yaml.
440
+ *
441
+ * @param {string} content - Encrypted YAML content string
442
+ * @param {string} kmsKeyId - KMS key ID (required)
443
+ * @param {Object} [options] - Options (same as decryptKMSObject)
444
+ * @returns {Promise<Object>} Result with decrypted YAML content
445
+ */
446
+ async function decryptKMSYamlContent(content, kmsKeyId, options = {}) {
447
+ validateKmsKeyId(kmsKeyId);
448
+
449
+ if (!content || typeof content !== 'string') {
450
+ throw new ContentError(
451
+ 'Content must be a non-empty string',
452
+ CONTENT_ERROR_CODES.EMPTY_CONTENT,
453
+ 'yaml'
454
+ );
455
+ }
456
+
457
+ // Log warning if js-yaml not available
458
+ const opts = buildDecryptOptions(options);
459
+ if (!isJsYamlAvailable()) {
460
+ opts.logger?.warn?.(
461
+ '[decryptKMSYamlContent] js-yaml not installed. Using simple parser (limited features).'
462
+ );
463
+ }
464
+
465
+ const obj = parseYaml(content);
466
+ const objectResult = await decryptKMSObject(obj, kmsKeyId, options);
467
+
468
+ return {
469
+ content: serializeYaml(objectResult.object),
470
+ decrypted: objectResult.decrypted,
471
+ skipped: objectResult.skipped,
472
+ failed: objectResult.failed
473
+ };
474
+ }
475
+
476
+ // ═══════════════════════════════════════════════════════════════════════════
477
+ // EXPORTS
478
+ // ═══════════════════════════════════════════════════════════════════════════
479
+
480
+ module.exports = {
481
+ // ENV operations
482
+ encryptKMSEnvContent,
483
+ decryptKMSEnvContent,
484
+ parseEnvContent,
485
+ reconstructEnvContent,
486
+
487
+ // JSON operations
488
+ encryptKMSJsonContent,
489
+ decryptKMSJsonContent,
490
+
491
+ // YAML operations
492
+ encryptKMSYamlContent,
493
+ decryptKMSYamlContent
494
+ };