@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/LICENSE +21 -0
- package/README.md +1203 -0
- package/SECURITY.md +505 -0
- package/bin/cli.js +969 -0
- package/package.json +77 -0
- package/src/attestation/attestation-client.js +146 -0
- package/src/attestation/attestation-manager.js +339 -0
- package/src/attestation/cms-unwrap.js +166 -0
- package/src/attestation/index.js +66 -0
- package/src/attestation/key-pair.js +129 -0
- package/src/config.js +130 -0
- package/src/content-operations.js +494 -0
- package/src/errors.js +372 -0
- package/src/index.d.ts +641 -0
- package/src/index.js +438 -0
- package/src/keystore.js +678 -0
- package/src/kms.js +858 -0
- package/src/object-operations.js +232 -0
- package/src/options.js +541 -0
- package/src/path-matcher.js +319 -0
- package/src/rotate.js +92 -0
- package/src/yaml-utils.js +265 -0
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
|
+
};
|