@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.
package/src/kms.js ADDED
@@ -0,0 +1,858 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - KMS Operations
3
+ *
4
+ * Core KMS encryption and decryption operations.
5
+ * Uses AWS SDK directly with support for attestation.
6
+ */
7
+
8
+ const crypto = require('node:crypto');
9
+ const {
10
+ KMSClient,
11
+ EncryptCommand,
12
+ DecryptCommand,
13
+ DescribeKeyCommand
14
+ } = require('@aws-sdk/client-kms');
15
+ const {
16
+ KmsError,
17
+ AttestationError,
18
+ KMS_ERROR_CODES,
19
+ ATTESTATION_ERROR_CODES,
20
+ createKmsErrorFromAws
21
+ } = require('./errors');
22
+ const { buildAwsSdkOptions, validateKmsKeyId } = require('./options');
23
+
24
+ // Lazy-load attestation module to avoid circular dependencies
25
+ let attestationModule = null;
26
+ function getAttestationModule() {
27
+ if (!attestationModule) {
28
+ attestationModule = require('./attestation');
29
+ }
30
+ return attestationModule;
31
+ }
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // ENCRYPTED VALUE FORMAT
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ const ENCRYPTED_PREFIX = 'ENC[';
38
+ const ENCRYPTED_SUFFIX = ']';
39
+
40
+ /**
41
+ * Check if a value is in encrypted format (ENC[...])
42
+ * @param {string} value - Value to check
43
+ * @returns {boolean}
44
+ */
45
+ function isEncryptedFormat(value) {
46
+ if (!value || typeof value !== 'string') return false;
47
+ return value.startsWith(ENCRYPTED_PREFIX) && value.endsWith(ENCRYPTED_SUFFIX);
48
+ }
49
+
50
+ /**
51
+ * Check if a value looks like KMS ciphertext (base64 starting with AQICAH)
52
+ * @param {string} value - Value to check
53
+ * @returns {boolean}
54
+ */
55
+ function isKmsCiphertext(value) {
56
+ if (!value || typeof value !== 'string') return false;
57
+ return /^AQICAH/.test(value) && /^[A-Za-z0-9+/=]{50,}$/.test(value);
58
+ }
59
+
60
+ /**
61
+ * Check if a value is already encrypted (ENC[...] or raw KMS ciphertext)
62
+ * @param {string} value - Value to check
63
+ * @returns {boolean}
64
+ */
65
+ function isAlreadyEncrypted(value) {
66
+ return isEncryptedFormat(value) || isKmsCiphertext(value);
67
+ }
68
+
69
+ /**
70
+ * Wrap ciphertext in ENC[...] format
71
+ * @param {string} ciphertext - Base64 ciphertext
72
+ * @returns {string}
73
+ */
74
+ function wrapCiphertext(ciphertext) {
75
+ return `${ENCRYPTED_PREFIX}${ciphertext}${ENCRYPTED_SUFFIX}`;
76
+ }
77
+
78
+ /**
79
+ * Unwrap ciphertext from ENC[...] format
80
+ * @param {string} value - Wrapped ciphertext
81
+ * @returns {string} Raw base64 ciphertext
82
+ */
83
+ function unwrapCiphertext(value) {
84
+ if (isEncryptedFormat(value)) {
85
+ return value.slice(ENCRYPTED_PREFIX.length, -ENCRYPTED_SUFFIX.length);
86
+ }
87
+ return value;
88
+ }
89
+
90
+ // Envelope encryption for asymmetric (RSA) keys only.
91
+ // Payload: version(1) || encDEKLen(2 BE) || encryptedDEK || iv(12) || ciphertext || tag(16)
92
+ const ENVELOPE_VERSION = 0x01;
93
+ const DEK_LENGTH = 32;
94
+ const IV_LENGTH = 12;
95
+ const GCM_TAG_LENGTH = 16;
96
+
97
+ /**
98
+ * Check if ciphertext buffer is envelope format (first byte = ENVELOPE_VERSION)
99
+ * @param {Buffer} buf - Decoded ciphertext buffer
100
+ * @returns {boolean}
101
+ */
102
+ function isEnvelopeFormat(buf) {
103
+ return Buffer.isBuffer(buf) && buf.length > 1 && buf[0] === ENVELOPE_VERSION;
104
+ }
105
+
106
+ // ═══════════════════════════════════════════════════════════════════════════
107
+ // KMS CLIENT MANAGEMENT
108
+ // ═══════════════════════════════════════════════════════════════════════════
109
+
110
+ // Cache for KMS clients (keyed by region + credentials hash)
111
+ const clientCache = new Map();
112
+
113
+ /**
114
+ * Get or create a KMS client
115
+ * @param {Object} options - AWS options
116
+ * @returns {KMSClient}
117
+ */
118
+ function getKmsClient(options = {}) {
119
+ const sdkOptions = buildAwsSdkOptions(options);
120
+ const cacheKey = JSON.stringify(sdkOptions);
121
+
122
+ if (clientCache.has(cacheKey)) {
123
+ return clientCache.get(cacheKey);
124
+ }
125
+
126
+ const client = new KMSClient(sdkOptions);
127
+ clientCache.set(cacheKey, client);
128
+ return client;
129
+ }
130
+
131
+ // ═══════════════════════════════════════════════════════════════════════════
132
+ // KEY DETECTION
133
+ // ═══════════════════════════════════════════════════════════════════════════
134
+
135
+ // Cache for key algorithms
136
+ const keyAlgorithmCache = new Map();
137
+
138
+ /**
139
+ * Detect if KMS key is RSA (asymmetric) or symmetric
140
+ * @param {KMSClient} client - KMS client
141
+ * @param {string} kmsKeyId - KMS key ID
142
+ * @param {Object} [logger] - Logger instance
143
+ * @returns {Promise<string|null>} Algorithm name or null for symmetric
144
+ */
145
+ async function detectKeyAlgorithm(client, kmsKeyId, logger) {
146
+ // Check cache
147
+ if (keyAlgorithmCache.has(kmsKeyId)) {
148
+ return keyAlgorithmCache.get(kmsKeyId);
149
+ }
150
+
151
+ try {
152
+ const command = new DescribeKeyCommand({ KeyId: kmsKeyId });
153
+ const response = await client.send(command);
154
+
155
+ const keySpec = response.KeyMetadata?.KeySpec;
156
+ let algorithm = null;
157
+
158
+ if (['RSA_2048', 'RSA_3072', 'RSA_4096'].includes(keySpec)) {
159
+ algorithm = 'RSAES_OAEP_SHA_256';
160
+ logger?.debug?.(`[KMS] Detected RSA key (${keySpec}), using ${algorithm}`);
161
+ } else {
162
+ logger?.debug?.('[KMS] Detected symmetric key');
163
+ }
164
+
165
+ keyAlgorithmCache.set(kmsKeyId, algorithm);
166
+ return algorithm;
167
+ } catch (error) {
168
+ logger?.warn?.(`[KMS] Could not detect key type: ${error.message}`);
169
+ // Default to trying RSA first
170
+ return 'RSAES_OAEP_SHA_256';
171
+ }
172
+ }
173
+
174
+ // ═══════════════════════════════════════════════════════════════════════════
175
+ // ATTESTATION HANDLING
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+
178
+ // Cache for AttestationManager instances (keyed by endpoint)
179
+ const attestationManagerCache = new Map();
180
+
181
+ /**
182
+ * Get or create an AttestationManager
183
+ * @param {Object} attestation - Attestation options
184
+ * @param {Object} [logger] - Logger instance
185
+ * @returns {Promise<import('./attestation').AttestationManager>}
186
+ */
187
+ async function getAttestationManager(attestation, logger) {
188
+ const { AttestationManager } = getAttestationModule();
189
+
190
+ const endpoint = attestation.endpoint || getAttestationModule().DEFAULT_ATTESTATION_ENDPOINT;
191
+ const cacheKey = endpoint;
192
+
193
+ // Check cache
194
+ if (attestationManagerCache.has(cacheKey)) {
195
+ const cached = attestationManagerCache.get(cacheKey);
196
+ if (cached.isInitialized()) {
197
+ return cached;
198
+ }
199
+ }
200
+
201
+ // Create new manager
202
+ const manager = new AttestationManager({
203
+ endpoint,
204
+ timeout: attestation.timeout || 10000,
205
+ userData: attestation.userData || '',
206
+ logger
207
+ });
208
+
209
+ await manager.initialize();
210
+ attestationManagerCache.set(cacheKey, manager);
211
+
212
+ return manager;
213
+ }
214
+
215
+ /**
216
+ * Get attestation document from options (legacy mode - pre-generated document)
217
+ * @param {Object} attestation - Attestation options
218
+ * @returns {Promise<{ document: Buffer, privateKey: string } | null>}
219
+ */
220
+ async function getAttestationDocumentLegacy(attestation) {
221
+ if (!attestation?.enabled) {
222
+ return null;
223
+ }
224
+
225
+ // Check for pre-generated document + private key (legacy mode)
226
+ if (attestation.document && attestation.privateKey) {
227
+ let doc = attestation.document;
228
+ if (typeof doc === 'function') {
229
+ doc = await doc();
230
+ }
231
+ if (typeof doc === 'string') {
232
+ doc = Buffer.from(doc, 'base64');
233
+ }
234
+ return {
235
+ document: doc,
236
+ privateKey: attestation.privateKey
237
+ };
238
+ }
239
+
240
+ return null;
241
+ }
242
+
243
+ // NOTE: The 5-minute attestation age-limit detection and refresh-and-retry is
244
+ // handled inside AttestationManager (_isAgeLimitError + reinitialize + retry),
245
+ // so no duplicate helper is needed here.
246
+
247
+ // ═══════════════════════════════════════════════════════════════════════════
248
+ // ENVELOPE ENCRYPTION (RSA / ASYMMETRIC KEYS ONLY)
249
+ // ═══════════════════════════════════════════════════════════════════════════
250
+
251
+ /**
252
+ * Encrypt using envelope encryption: DEK encrypts plaintext (AES-256-GCM), KMS encrypts DEK (RSA).
253
+ * Used for asymmetric keys so plaintext size is unlimited.
254
+ *
255
+ * @param {string} plaintext - Value to encrypt
256
+ * @param {string} kmsKeyId - KMS key ID (RSA)
257
+ * @param {Object} options - Options (client, algorithm, output, logger)
258
+ * @returns {Promise<string|Buffer>} Envelope blob (version || encDEKLen || encryptedDEK || iv || ciphertext || tag)
259
+ * @private
260
+ */
261
+ async function encryptEnvelopeRSA(plaintext, kmsKeyId, options = {}) {
262
+ const client = options.client;
263
+ const algorithm = options.algorithm || 'RSAES_OAEP_SHA_256';
264
+ const outputFormat = options.output?.format || 'prefixed';
265
+
266
+ const plaintextBuffer = Buffer.from(plaintext, 'utf-8');
267
+ const dek = crypto.randomBytes(DEK_LENGTH);
268
+ const iv = crypto.randomBytes(IV_LENGTH);
269
+
270
+ const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv);
271
+ const ciphertext = Buffer.concat([cipher.update(plaintextBuffer), cipher.final()]);
272
+ const authTag = cipher.getAuthTag();
273
+
274
+ const encryptCommand = new EncryptCommand({
275
+ KeyId: kmsKeyId,
276
+ Plaintext: dek,
277
+ EncryptionAlgorithm: algorithm
278
+ });
279
+ const encResponse = await client.send(encryptCommand);
280
+ const encryptedDEK = Buffer.from(encResponse.CiphertextBlob);
281
+
282
+ const encDEKLen = encryptedDEK.length;
283
+ const envelope = Buffer.allocUnsafe(
284
+ 1 + 2 + encDEKLen + IV_LENGTH + ciphertext.length + GCM_TAG_LENGTH
285
+ );
286
+ let offset = 0;
287
+ envelope[offset++] = ENVELOPE_VERSION;
288
+ envelope.writeUInt16BE(encDEKLen, offset);
289
+ offset += 2;
290
+ encryptedDEK.copy(envelope, offset);
291
+ offset += encDEKLen;
292
+ iv.copy(envelope, offset);
293
+ offset += IV_LENGTH;
294
+ ciphertext.copy(envelope, offset);
295
+ offset += ciphertext.length;
296
+ authTag.copy(envelope, offset);
297
+
298
+ switch (outputFormat) {
299
+ case 'buffer':
300
+ return envelope;
301
+ case 'base64':
302
+ return envelope.toString('base64');
303
+ case 'prefixed':
304
+ default:
305
+ return wrapCiphertext(envelope.toString('base64'));
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Decrypt envelope format: KMS decrypts encrypted DEK, then AES-256-GCM decrypt with DEK.
311
+ *
312
+ * @param {KMSClient} client - KMS client
313
+ * @param {Buffer} envelope - Envelope blob (starts with ENVELOPE_VERSION)
314
+ * @param {string} kmsKeyId - KMS key ID
315
+ * @param {Object} options - Options (logger)
316
+ * @returns {Promise<string>} Decrypted plaintext
317
+ * @private
318
+ */
319
+ async function decryptEnvelopeRSA(client, envelope, kmsKeyId, _options = {}) {
320
+ const encDEKLen = envelope.readUInt16BE(1);
321
+ const encryptedDEK = envelope.subarray(3, 3 + encDEKLen);
322
+ const iv = envelope.subarray(3 + encDEKLen, 3 + encDEKLen + IV_LENGTH);
323
+ const tag = envelope.subarray(envelope.length - GCM_TAG_LENGTH);
324
+ const ciphertext = envelope.subarray(
325
+ 3 + encDEKLen + IV_LENGTH,
326
+ envelope.length - GCM_TAG_LENGTH
327
+ );
328
+
329
+ const decryptCommand = new DecryptCommand({
330
+ KeyId: kmsKeyId,
331
+ CiphertextBlob: encryptedDEK,
332
+ EncryptionAlgorithm: 'RSAES_OAEP_SHA_256'
333
+ });
334
+ const decResponse = await client.send(decryptCommand);
335
+ if (!decResponse.Plaintext) {
336
+ throw new KmsError(
337
+ 'KMS did not return plaintext (envelope DEK)',
338
+ KMS_ERROR_CODES.DECRYPT_FAILED,
339
+ kmsKeyId
340
+ );
341
+ }
342
+ const dek = Buffer.from(decResponse.Plaintext);
343
+
344
+ const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv);
345
+ decipher.setAuthTag(tag);
346
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
347
+ return plaintext.toString('utf-8');
348
+ }
349
+
350
+ // ═══════════════════════════════════════════════════════════════════════════
351
+ // CORE ENCRYPT FUNCTION
352
+ // ═══════════════════════════════════════════════════════════════════════════
353
+
354
+ /**
355
+ * Encrypt a single value using AWS KMS.
356
+ * For asymmetric (RSA) keys uses envelope encryption (no plaintext size limit).
357
+ * For symmetric keys uses direct KMS Encrypt (plaintext max 4KB).
358
+ *
359
+ * @param {string} plaintext - Value to encrypt
360
+ * @param {string} kmsKeyId - KMS key ID (required)
361
+ * @param {Object} [options] - Encrypt options
362
+ * @param {Object} [options.aws] - AWS options
363
+ * @param {Object} [options.output] - Output format options
364
+ * @param {string} [options.output.format='prefixed'] - 'base64' | 'buffer' | 'prefixed'
365
+ * @param {Object} [options.logger] - Logger instance
366
+ * @returns {Promise<string|Buffer>} Encrypted value
367
+ */
368
+ async function encryptKMSValue(plaintext, kmsKeyId, options = {}) {
369
+ validateKmsKeyId(kmsKeyId);
370
+
371
+ const logger = options.logger;
372
+ const outputFormat = options.output?.format || 'prefixed';
373
+
374
+ const client = getKmsClient(options);
375
+ const algorithm = await detectKeyAlgorithm(client, kmsKeyId, logger);
376
+
377
+ if (algorithm) {
378
+ logger?.debug?.('[KMS] Asymmetric key detected, using envelope encryption');
379
+ return await encryptEnvelopeRSA(plaintext, kmsKeyId, {
380
+ client,
381
+ algorithm,
382
+ output: { format: outputFormat },
383
+ logger
384
+ });
385
+ }
386
+
387
+ const commandOptions = {
388
+ KeyId: kmsKeyId,
389
+ Plaintext: Buffer.from(plaintext, 'utf-8')
390
+ };
391
+
392
+ try {
393
+ const command = new EncryptCommand(commandOptions);
394
+ const response = await client.send(command);
395
+ const ciphertextBuffer = Buffer.from(response.CiphertextBlob);
396
+
397
+ switch (outputFormat) {
398
+ case 'buffer':
399
+ return ciphertextBuffer;
400
+ case 'base64':
401
+ return ciphertextBuffer.toString('base64');
402
+ case 'prefixed':
403
+ default:
404
+ return wrapCiphertext(ciphertextBuffer.toString('base64'));
405
+ }
406
+ } catch (error) {
407
+ throw createKmsErrorFromAws(error, kmsKeyId, 'encrypt');
408
+ }
409
+ }
410
+
411
+ // ═══════════════════════════════════════════════════════════════════════════
412
+ // CORE DECRYPT FUNCTION
413
+ // ═══════════════════════════════════════════════════════════════════════════
414
+
415
+ /**
416
+ * Decrypt a single value using AWS KMS
417
+ *
418
+ * @param {string|Buffer} ciphertext - Value to decrypt
419
+ * @param {string} kmsKeyId - KMS key ID (required)
420
+ * @param {Object} [options] - Decrypt options
421
+ * @param {Object} [options.aws] - AWS options
422
+ * @param {Object} [options.attestation] - Attestation options
423
+ * @param {Object} [options.input] - Input format options
424
+ * @param {string} [options.input.format='auto'] - 'auto' | 'base64' | 'buffer' | 'prefixed'
425
+ * @param {Object} [options.logger] - Logger instance
426
+ * @returns {Promise<string>} Decrypted plaintext
427
+ */
428
+ async function decryptKMSValue(ciphertext, kmsKeyId, options = {}) {
429
+ validateKmsKeyId(kmsKeyId);
430
+
431
+ const logger = options.logger;
432
+ const inputFormat = options.input?.format || 'auto';
433
+ const attestation = options.attestation;
434
+
435
+ // Convert ciphertext to buffer
436
+ let ciphertextBuffer;
437
+ if (Buffer.isBuffer(ciphertext)) {
438
+ ciphertextBuffer = ciphertext;
439
+ } else if (typeof ciphertext === 'string') {
440
+ // Handle different input formats
441
+ let base64String = ciphertext;
442
+ if (inputFormat === 'auto' || inputFormat === 'prefixed') {
443
+ base64String = unwrapCiphertext(ciphertext);
444
+ }
445
+ ciphertextBuffer = Buffer.from(base64String, 'base64');
446
+ } else {
447
+ throw new KmsError(
448
+ 'Invalid ciphertext format',
449
+ KMS_ERROR_CODES.INVALID_CIPHERTEXT,
450
+ kmsKeyId
451
+ );
452
+ }
453
+
454
+ const client = getKmsClient(options);
455
+
456
+ if (isEnvelopeFormat(ciphertextBuffer)) {
457
+ return await decryptEnvelopeRSA(client, ciphertextBuffer, kmsKeyId, options);
458
+ }
459
+
460
+ const algorithm = await detectKeyAlgorithm(client, kmsKeyId, logger);
461
+
462
+ // Try to decrypt with attestation if enabled
463
+ if (attestation?.enabled) {
464
+ return await decryptWithAttestation(client, ciphertextBuffer, kmsKeyId, algorithm, options);
465
+ }
466
+
467
+ // Standard decrypt
468
+ return await decryptStandard(client, ciphertextBuffer, kmsKeyId, algorithm, options);
469
+ }
470
+
471
+ /**
472
+ * Decrypt with attestation support
473
+ *
474
+ * Supports two modes:
475
+ * 1. Full attestation (default): Uses AttestationManager to generate key pairs,
476
+ * fetch attestation documents, and unwrap CMS EnvelopedData
477
+ * 2. Legacy mode: Uses pre-generated document + privateKey from options
478
+ *
479
+ * @private
480
+ */
481
+ async function decryptWithAttestation(client, ciphertextBuffer, kmsKeyId, algorithm, options) {
482
+ const { attestation, logger } = options;
483
+
484
+ // Check for legacy mode (pre-generated document + private key)
485
+ const legacyMaterials = await getAttestationDocumentLegacy(attestation);
486
+ if (legacyMaterials) {
487
+ logger?.debug?.('[KMS] Using legacy attestation mode (pre-generated document)');
488
+ return await decryptWithAttestationMaterials(
489
+ client,
490
+ ciphertextBuffer,
491
+ kmsKeyId,
492
+ algorithm,
493
+ legacyMaterials,
494
+ logger
495
+ );
496
+ }
497
+
498
+ // Full attestation mode - use AttestationManager
499
+ logger?.debug?.('[KMS] Using full attestation mode with AttestationManager');
500
+
501
+ let manager;
502
+ try {
503
+ manager = await getAttestationManager(attestation, logger);
504
+ } catch (error) {
505
+ if (attestation.required) {
506
+ throw new AttestationError(
507
+ `Failed to initialize attestation: ${error.message}`,
508
+ ATTESTATION_ERROR_CODES.INIT_FAILED,
509
+ error
510
+ );
511
+ }
512
+
513
+ // Fallback to standard if allowed
514
+ if (attestation.fallbackToStandard !== false) {
515
+ logger?.warn?.(
516
+ `[KMS] Attestation init failed, falling back to standard: ${error.message}`
517
+ );
518
+ return await decryptStandard(client, ciphertextBuffer, kmsKeyId, algorithm, options);
519
+ }
520
+
521
+ throw new AttestationError(
522
+ `Attestation initialization failed and fallback disabled: ${error.message}`,
523
+ ATTESTATION_ERROR_CODES.INIT_FAILED,
524
+ error
525
+ );
526
+ }
527
+
528
+ // Use AttestationManager to decrypt (handles 5-minute refresh internally)
529
+ try {
530
+ const plaintext = await manager.decryptWithAttestation(client, ciphertextBuffer, kmsKeyId, {
531
+ encryptionAlgorithm: algorithm,
532
+ encryptionContext: attestation.encryptionContext
533
+ });
534
+ return plaintext.toString('utf-8');
535
+ } catch (error) {
536
+ // Check if we should fallback
537
+ if (attestation.fallbackToStandard !== false) {
538
+ logger?.warn?.(`[KMS] Attestation failed, falling back to standard: ${error.message}`);
539
+ return await decryptStandard(client, ciphertextBuffer, kmsKeyId, algorithm, options);
540
+ }
541
+ throw error;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Decrypt with pre-generated attestation materials (legacy mode)
547
+ *
548
+ * This mode is for when the caller has already generated the key pair
549
+ * and fetched the attestation document themselves.
550
+ *
551
+ * @private
552
+ */
553
+ async function decryptWithAttestationMaterials(
554
+ client,
555
+ ciphertextBuffer,
556
+ kmsKeyId,
557
+ algorithm,
558
+ materials,
559
+ logger
560
+ ) {
561
+ const { document: attestationDoc, privateKey } = materials;
562
+ const { unwrapCms } = getAttestationModule();
563
+
564
+ const commandOptions = {
565
+ CiphertextBlob: ciphertextBuffer,
566
+ KeyId: kmsKeyId,
567
+ Recipient: {
568
+ KeyEncryptionAlgorithm: 'RSAES_OAEP_SHA_256',
569
+ AttestationDocument: attestationDoc
570
+ }
571
+ };
572
+
573
+ if (algorithm) {
574
+ commandOptions.EncryptionAlgorithm = algorithm;
575
+ }
576
+
577
+ logger?.debug?.('[KMS] Sending KMS Decrypt with Recipient...');
578
+ const command = new DecryptCommand(commandOptions);
579
+ const response = await client.send(command);
580
+
581
+ // With Recipient, KMS returns CiphertextForRecipient, NOT Plaintext
582
+ if (!response.CiphertextForRecipient) {
583
+ throw new KmsError(
584
+ 'KMS did not return CiphertextForRecipient - check key policy and Recipient support',
585
+ KMS_ERROR_CODES.DECRYPT_FAILED,
586
+ kmsKeyId
587
+ );
588
+ }
589
+
590
+ const ciphertextForRecipient = Buffer.from(response.CiphertextForRecipient);
591
+ logger?.debug?.(
592
+ `[KMS] Received CiphertextForRecipient (${ciphertextForRecipient.length} bytes)`
593
+ );
594
+
595
+ // Unwrap CMS EnvelopedData using the private key
596
+ logger?.debug?.('[KMS] Unwrapping CMS EnvelopedData...');
597
+ const plaintext = await unwrapCms(ciphertextForRecipient, privateKey);
598
+ logger?.debug?.(`[KMS] Decrypted plaintext (${plaintext.length} bytes)`);
599
+
600
+ return plaintext.toString('utf-8');
601
+ }
602
+
603
+ /**
604
+ * Standard decrypt without attestation
605
+ * @private
606
+ */
607
+ async function decryptStandard(client, ciphertextBuffer, kmsKeyId, algorithm, options) {
608
+ const logger = options?.logger;
609
+
610
+ const commandOptions = {
611
+ CiphertextBlob: ciphertextBuffer,
612
+ KeyId: kmsKeyId
613
+ };
614
+
615
+ if (algorithm) {
616
+ commandOptions.EncryptionAlgorithm = algorithm;
617
+ }
618
+
619
+ try {
620
+ const command = new DecryptCommand(commandOptions);
621
+ const response = await client.send(command);
622
+
623
+ if (!response.Plaintext) {
624
+ throw new KmsError(
625
+ 'KMS did not return plaintext',
626
+ KMS_ERROR_CODES.DECRYPT_FAILED,
627
+ kmsKeyId
628
+ );
629
+ }
630
+
631
+ return Buffer.from(response.Plaintext).toString('utf-8');
632
+ } catch (error) {
633
+ // If RSA algorithm failed, try without algorithm (symmetric)
634
+ if (
635
+ algorithm === 'RSAES_OAEP_SHA_256' &&
636
+ (error.message?.includes('incompatible') || error.name?.includes('InvalidCiphertext'))
637
+ ) {
638
+ logger?.debug?.('[KMS] RSA algorithm failed, trying symmetric...');
639
+
640
+ // Clear cached algorithm
641
+ keyAlgorithmCache.delete(kmsKeyId);
642
+
643
+ const fallbackCommand = new DecryptCommand({
644
+ CiphertextBlob: ciphertextBuffer,
645
+ KeyId: kmsKeyId
646
+ });
647
+ const response = await client.send(fallbackCommand);
648
+
649
+ if (!response.Plaintext) {
650
+ throw new KmsError(
651
+ 'KMS did not return plaintext',
652
+ KMS_ERROR_CODES.DECRYPT_FAILED,
653
+ kmsKeyId
654
+ );
655
+ }
656
+
657
+ return Buffer.from(response.Plaintext).toString('utf-8');
658
+ }
659
+
660
+ throw createKmsErrorFromAws(error, kmsKeyId, 'decrypt');
661
+ }
662
+ }
663
+
664
+ // ═══════════════════════════════════════════════════════════════════════════
665
+ // BATCH OPERATIONS
666
+ // ═══════════════════════════════════════════════════════════════════════════
667
+
668
+ /**
669
+ * Encrypt multiple values using AWS KMS
670
+ *
671
+ * @param {Object} values - Key-value pairs to encrypt
672
+ * @param {string} kmsKeyId - KMS key ID (required)
673
+ * @param {Object} [options] - Encrypt options
674
+ * @returns {Promise<Object>} Result with encrypted values
675
+ */
676
+ async function encryptKMSValues(values, kmsKeyId, options = {}) {
677
+ validateKmsKeyId(kmsKeyId);
678
+
679
+ const result = {
680
+ values: {},
681
+ encrypted: [],
682
+ skipped: [],
683
+ failed: []
684
+ };
685
+
686
+ const skipEmpty = options.skip?.empty !== false;
687
+ const skipAlreadyEncrypted = options.skip?.alreadyEncrypted !== false;
688
+ const continueOnError = options.continueOnError === true;
689
+
690
+ for (const [key, value] of Object.entries(values)) {
691
+ // Skip empty values
692
+ if (skipEmpty && (!value || (typeof value === 'string' && value.trim() === ''))) {
693
+ result.values[key] = value;
694
+ result.skipped.push(key);
695
+ continue;
696
+ }
697
+
698
+ // Skip already encrypted
699
+ if (skipAlreadyEncrypted && isAlreadyEncrypted(value)) {
700
+ result.values[key] = value;
701
+ result.skipped.push(key);
702
+ continue;
703
+ }
704
+
705
+ try {
706
+ result.values[key] = await encryptKMSValue(value, kmsKeyId, options);
707
+ result.encrypted.push(key);
708
+ } catch (error) {
709
+ if (continueOnError) {
710
+ result.values[key] = value;
711
+ result.failed.push({ key, error });
712
+ } else {
713
+ throw error;
714
+ }
715
+ }
716
+ }
717
+
718
+ return result;
719
+ }
720
+
721
+ /**
722
+ * Decrypt multiple values using AWS KMS
723
+ *
724
+ * @param {Object} values - Key-value pairs to decrypt
725
+ * @param {string} kmsKeyId - KMS key ID (required)
726
+ * @param {Object} [options] - Decrypt options
727
+ * @returns {Promise<Object>} Result with decrypted values
728
+ */
729
+ async function decryptKMSValues(values, kmsKeyId, options = {}) {
730
+ validateKmsKeyId(kmsKeyId);
731
+
732
+ const result = {
733
+ values: {},
734
+ decrypted: [],
735
+ skipped: [],
736
+ failed: []
737
+ };
738
+
739
+ const skipUnencrypted = options.skip?.unencrypted !== false;
740
+ const continueOnError = options.continueOnError === true;
741
+
742
+ for (const [key, value] of Object.entries(values)) {
743
+ // Skip unencrypted values
744
+ if (skipUnencrypted && !isAlreadyEncrypted(value)) {
745
+ result.values[key] = value;
746
+ result.skipped.push(key);
747
+ continue;
748
+ }
749
+
750
+ try {
751
+ result.values[key] = await decryptKMSValue(value, kmsKeyId, options);
752
+ result.decrypted.push(key);
753
+ } catch (error) {
754
+ if (continueOnError) {
755
+ result.values[key] = value;
756
+ result.failed.push({ key, error });
757
+ } else {
758
+ throw error;
759
+ }
760
+ }
761
+ }
762
+
763
+ return result;
764
+ }
765
+
766
+ // ═══════════════════════════════════════════════════════════════════════════
767
+ // UTILITY FUNCTIONS
768
+ // ═══════════════════════════════════════════════════════════════════════════
769
+
770
+ /**
771
+ * Mask a KMS key ID for logging
772
+ * @param {string} keyId - KMS key ID
773
+ * @returns {string}
774
+ */
775
+ function maskKmsKeyId(keyId) {
776
+ if (!keyId) return 'undefined';
777
+
778
+ // Handle ARN format
779
+ if (keyId.includes('arn:aws:kms')) {
780
+ const parts = keyId.split('/');
781
+ if (parts.length === 2) {
782
+ return `${parts[0]}/${parts[1].substring(0, 8)}...`;
783
+ }
784
+ return keyId.substring(0, 40) + '...';
785
+ }
786
+
787
+ // Handle UUID format
788
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(keyId)) {
789
+ return `${keyId.substring(0, 8)}-****-****-****-${keyId.substring(keyId.length - 4)}`;
790
+ }
791
+
792
+ // Handle alias format
793
+ if (keyId.startsWith('alias/')) {
794
+ return keyId; // Aliases are not sensitive
795
+ }
796
+
797
+ // Fallback
798
+ return keyId.substring(0, 8) + '...' + keyId.substring(keyId.length - 4);
799
+ }
800
+
801
+ // ═══════════════════════════════════════════════════════════════════════════
802
+ // ATTESTATION UTILITIES
803
+ // ═══════════════════════════════════════════════════════════════════════════
804
+
805
+ /**
806
+ * Clear all cached attestation managers
807
+ * Useful for testing or when you need to force re-initialization
808
+ */
809
+ function clearAttestationCache() {
810
+ for (const manager of attestationManagerCache.values()) {
811
+ manager.destroy();
812
+ }
813
+ attestationManagerCache.clear();
814
+ }
815
+
816
+ /**
817
+ * Get attestation status for a given endpoint
818
+ * @param {string} [endpoint] - Attestation endpoint (uses default if not provided)
819
+ * @returns {Object | null}
820
+ */
821
+ function getAttestationStatus(endpoint) {
822
+ const key = endpoint || getAttestationModule().DEFAULT_ATTESTATION_ENDPOINT;
823
+ const manager = attestationManagerCache.get(key);
824
+ return manager ? manager.getStatus() : null;
825
+ }
826
+
827
+ // ═══════════════════════════════════════════════════════════════════════════
828
+ // EXPORTS
829
+ // ═══════════════════════════════════════════════════════════════════════════
830
+
831
+ module.exports = {
832
+ // Core KMS operations
833
+ encryptKMSValue,
834
+ decryptKMSValue,
835
+ encryptKMSValues,
836
+ decryptKMSValues,
837
+
838
+ // Format helpers
839
+ isEncryptedFormat,
840
+ isKmsCiphertext,
841
+ isAlreadyEncrypted,
842
+ isEnvelopeFormat,
843
+ wrapCiphertext,
844
+ unwrapCiphertext,
845
+
846
+ // Utilities
847
+ getKmsClient,
848
+ detectKeyAlgorithm,
849
+ maskKmsKeyId,
850
+
851
+ // Attestation utilities
852
+ clearAttestationCache,
853
+ getAttestationStatus,
854
+
855
+ // Constants
856
+ ENCRYPTED_PREFIX,
857
+ ENCRYPTED_SUFFIX
858
+ };