@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - CMS EnvelopedData Unwrapping
|
|
3
|
+
*
|
|
4
|
+
* Uses PKIjs and asn1js to unwrap CMS EnvelopedData returned by AWS KMS
|
|
5
|
+
* when using the Recipient parameter with attestation.
|
|
6
|
+
*
|
|
7
|
+
* When KMS receives a Recipient with AttestationDocument, it returns
|
|
8
|
+
* CiphertextForRecipient instead of Plaintext. This CiphertextForRecipient
|
|
9
|
+
* is a CMS EnvelopedData structure encrypted with the public key from
|
|
10
|
+
* the attestation document.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const asn1js = require('asn1js');
|
|
15
|
+
const pkijs = require('pkijs');
|
|
16
|
+
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
// PKIJS ENGINE SETUP
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
// Use Node.js WebCrypto for PKIjs
|
|
22
|
+
const nodeCrypto = crypto.webcrypto;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize PKIjs engine with Node.js crypto
|
|
26
|
+
* Must be called before any PKIjs operations
|
|
27
|
+
*/
|
|
28
|
+
function initializePkijsEngine() {
|
|
29
|
+
const engine = new pkijs.CryptoEngine({
|
|
30
|
+
name: 'nodeEngine',
|
|
31
|
+
crypto: nodeCrypto,
|
|
32
|
+
subtle: nodeCrypto.subtle
|
|
33
|
+
});
|
|
34
|
+
pkijs.setEngine('nodeEngine', engine);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Initialize on module load
|
|
38
|
+
initializePkijsEngine();
|
|
39
|
+
|
|
40
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
// CMS UNWRAP
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Unwrap CMS EnvelopedData using PKIjs
|
|
46
|
+
*
|
|
47
|
+
* @param {Buffer} ciphertextForRecipient - The CMS EnvelopedData from KMS
|
|
48
|
+
* @param {string} privateKeyPem - The ephemeral private key (PKCS#8 PEM)
|
|
49
|
+
* @returns {Promise<Buffer>} - The decrypted plaintext
|
|
50
|
+
* @throws {Error} If parsing or decryption fails
|
|
51
|
+
*/
|
|
52
|
+
async function unwrapCms(ciphertextForRecipient, privateKeyPem) {
|
|
53
|
+
// Ensure engine is initialized
|
|
54
|
+
initializePkijsEngine();
|
|
55
|
+
|
|
56
|
+
// 1. Parse CMS DER structure
|
|
57
|
+
const derView = new Uint8Array(
|
|
58
|
+
ciphertextForRecipient.buffer,
|
|
59
|
+
ciphertextForRecipient.byteOffset,
|
|
60
|
+
ciphertextForRecipient.byteLength
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const asn1Result = asn1js.fromBER(derView);
|
|
64
|
+
if (asn1Result.offset === -1) {
|
|
65
|
+
throw new Error('Failed to parse CMS DER structure');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Parse ContentInfo
|
|
69
|
+
const contentInfo = new pkijs.ContentInfo({ schema: asn1Result.result });
|
|
70
|
+
|
|
71
|
+
// Verify it's EnvelopedData (OID: 1.2.840.113549.1.7.3)
|
|
72
|
+
if (contentInfo.contentType !== '1.2.840.113549.1.7.3') {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Expected CMS EnvelopedData (1.2.840.113549.1.7.3), got ${contentInfo.contentType}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. Parse EnvelopedData
|
|
79
|
+
const envelopedData = new pkijs.EnvelopedData({ schema: contentInfo.content });
|
|
80
|
+
|
|
81
|
+
// 4. Import private key for decryption
|
|
82
|
+
const privateKey = await importPrivateKey(privateKeyPem);
|
|
83
|
+
|
|
84
|
+
// 5. Decrypt the content
|
|
85
|
+
const decryptResult = await envelopedData.decrypt(0, {
|
|
86
|
+
recipientPrivateKey: privateKey,
|
|
87
|
+
crypto: nodeCrypto
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 6. Handle different return types from PKIjs
|
|
91
|
+
let plaintext;
|
|
92
|
+
if (typeof decryptResult === 'boolean') {
|
|
93
|
+
// PKIjs modifies envelopedData.encryptedContentInfo.encryptedContent in-place
|
|
94
|
+
const encryptedContent = envelopedData.encryptedContentInfo?.encryptedContent;
|
|
95
|
+
if (!encryptedContent) {
|
|
96
|
+
throw new Error('Decryption returned true but no content found');
|
|
97
|
+
}
|
|
98
|
+
plaintext = Buffer.from(encryptedContent.valueBlock.valueHexView);
|
|
99
|
+
} else if (decryptResult instanceof ArrayBuffer) {
|
|
100
|
+
plaintext = Buffer.from(decryptResult);
|
|
101
|
+
} else if (ArrayBuffer.isView(decryptResult)) {
|
|
102
|
+
plaintext = Buffer.from(
|
|
103
|
+
decryptResult.buffer,
|
|
104
|
+
decryptResult.byteOffset,
|
|
105
|
+
decryptResult.byteLength
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
throw new TypeError(`Unexpected decrypt result type: ${typeof decryptResult}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return plaintext;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Import PKCS#8 PEM private key for RSA-OAEP decryption
|
|
116
|
+
*
|
|
117
|
+
* @param {string} privateKeyPem - PKCS#8 PEM private key
|
|
118
|
+
* @returns {Promise<CryptoKey>}
|
|
119
|
+
*/
|
|
120
|
+
async function importPrivateKey(privateKeyPem) {
|
|
121
|
+
// Extract base64 from PEM
|
|
122
|
+
const base64 = privateKeyPem
|
|
123
|
+
.replaceAll('-----BEGIN PRIVATE KEY-----', '')
|
|
124
|
+
.replaceAll('-----END PRIVATE KEY-----', '')
|
|
125
|
+
.replaceAll(/\s+/g, '');
|
|
126
|
+
|
|
127
|
+
const pkcs8Buffer = Buffer.from(base64, 'base64');
|
|
128
|
+
|
|
129
|
+
// Import as RSA-OAEP key
|
|
130
|
+
const privateKey = await nodeCrypto.subtle.importKey(
|
|
131
|
+
'pkcs8',
|
|
132
|
+
pkcs8Buffer,
|
|
133
|
+
{
|
|
134
|
+
name: 'RSA-OAEP',
|
|
135
|
+
hash: 'SHA-256'
|
|
136
|
+
},
|
|
137
|
+
false, // not extractable
|
|
138
|
+
['decrypt']
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return privateKey;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate that a PEM string is a valid PKCS#8 private key
|
|
146
|
+
*
|
|
147
|
+
* @param {string} pem - The PEM string to validate
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function validatePrivateKeyFormat(pem) {
|
|
151
|
+
if (!pem || typeof pem !== 'string') {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasHeader = pem.includes('-----BEGIN PRIVATE KEY-----');
|
|
156
|
+
const hasFooter = pem.includes('-----END PRIVATE KEY-----');
|
|
157
|
+
|
|
158
|
+
return hasHeader && hasFooter;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
unwrapCms,
|
|
163
|
+
importPrivateKey,
|
|
164
|
+
validatePrivateKeyFormat,
|
|
165
|
+
initializePkijsEngine
|
|
166
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - Attestation Module
|
|
3
|
+
*
|
|
4
|
+
* Complete AWS Nitro Enclave attestation support including:
|
|
5
|
+
* - Ephemeral RSA key pair generation
|
|
6
|
+
* - Attestation document fetching from Anjuna/Nitro endpoints
|
|
7
|
+
* - CMS EnvelopedData unwrapping with PKIjs
|
|
8
|
+
* - Lifecycle management with 5-minute refresh
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Key pair utilities
|
|
12
|
+
const {
|
|
13
|
+
generateEphemeralKeyPair,
|
|
14
|
+
pemToDerPublic,
|
|
15
|
+
pemToDerPrivate,
|
|
16
|
+
toBase64Url,
|
|
17
|
+
toBase64,
|
|
18
|
+
generateNonce,
|
|
19
|
+
prepareAttestationParams
|
|
20
|
+
} = require('./key-pair');
|
|
21
|
+
|
|
22
|
+
// CMS unwrapping
|
|
23
|
+
const {
|
|
24
|
+
unwrapCms,
|
|
25
|
+
importPrivateKey,
|
|
26
|
+
validatePrivateKeyFormat,
|
|
27
|
+
initializePkijsEngine
|
|
28
|
+
} = require('./cms-unwrap');
|
|
29
|
+
|
|
30
|
+
// Attestation client
|
|
31
|
+
const {
|
|
32
|
+
fetchAttestationDocument,
|
|
33
|
+
isNitroEnclave,
|
|
34
|
+
isAttestationAvailable,
|
|
35
|
+
DEFAULT_ATTESTATION_ENDPOINT
|
|
36
|
+
} = require('./attestation-client');
|
|
37
|
+
|
|
38
|
+
// Attestation manager
|
|
39
|
+
const { AttestationManager, createAttestationManager } = require('./attestation-manager');
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
// Key pair
|
|
43
|
+
generateEphemeralKeyPair,
|
|
44
|
+
pemToDerPublic,
|
|
45
|
+
pemToDerPrivate,
|
|
46
|
+
toBase64Url,
|
|
47
|
+
toBase64,
|
|
48
|
+
generateNonce,
|
|
49
|
+
prepareAttestationParams,
|
|
50
|
+
|
|
51
|
+
// CMS
|
|
52
|
+
unwrapCms,
|
|
53
|
+
importPrivateKey,
|
|
54
|
+
validatePrivateKeyFormat,
|
|
55
|
+
initializePkijsEngine,
|
|
56
|
+
|
|
57
|
+
// Client
|
|
58
|
+
fetchAttestationDocument,
|
|
59
|
+
isNitroEnclave,
|
|
60
|
+
isAttestationAvailable,
|
|
61
|
+
DEFAULT_ATTESTATION_ENDPOINT,
|
|
62
|
+
|
|
63
|
+
// Manager
|
|
64
|
+
AttestationManager,
|
|
65
|
+
createAttestationManager
|
|
66
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - Ephemeral Key Pair Generation
|
|
3
|
+
*
|
|
4
|
+
* Generates RSA-4096 key pairs for attestation document requests.
|
|
5
|
+
* The public key is embedded in the attestation document, and the
|
|
6
|
+
* private key is used to unwrap the CMS EnvelopedData from KMS.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
// KEY PAIR GENERATION
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate an ephemeral RSA-4096 key pair for attestation
|
|
17
|
+
* @returns {{ publicKey: string, privateKey: string, publicKeyDer: Buffer, privateKeyDer: Buffer }}
|
|
18
|
+
*/
|
|
19
|
+
function generateEphemeralKeyPair() {
|
|
20
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
|
21
|
+
modulusLength: 4096,
|
|
22
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
23
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Export public key as DER (for attestation request)
|
|
27
|
+
const publicKeyDer = crypto
|
|
28
|
+
.createPublicKey({ key: publicKey, format: 'pem' })
|
|
29
|
+
.export({ format: 'der', type: 'spki' });
|
|
30
|
+
|
|
31
|
+
// Export private key as DER (for CMS unwrap with OpenSSL, if needed)
|
|
32
|
+
const privateKeyDer = crypto
|
|
33
|
+
.createPrivateKey({ key: privateKey, format: 'pem' })
|
|
34
|
+
.export({ format: 'der', type: 'pkcs1' });
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
publicKey, // PEM format
|
|
38
|
+
privateKey, // PEM format (PKCS#8)
|
|
39
|
+
publicKeyDer, // DER format (SPKI)
|
|
40
|
+
privateKeyDer // DER format (PKCS#1)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
45
|
+
// PEM/DER UTILITIES
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert PEM public key to DER format
|
|
50
|
+
* @param {string} pem - PEM-encoded public key
|
|
51
|
+
* @returns {Buffer}
|
|
52
|
+
*/
|
|
53
|
+
function pemToDerPublic(pem) {
|
|
54
|
+
const base64 = String(pem)
|
|
55
|
+
.replaceAll('-----BEGIN PUBLIC KEY-----', '')
|
|
56
|
+
.replaceAll('-----END PUBLIC KEY-----', '')
|
|
57
|
+
.replaceAll(/\s+/g, '');
|
|
58
|
+
return Buffer.from(base64, 'base64');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert PEM private key to DER format (PKCS#8)
|
|
63
|
+
* @param {string} pem - PEM-encoded private key
|
|
64
|
+
* @returns {Buffer}
|
|
65
|
+
*/
|
|
66
|
+
function pemToDerPrivate(pem) {
|
|
67
|
+
const base64 = String(pem)
|
|
68
|
+
.replaceAll('-----BEGIN PRIVATE KEY-----', '')
|
|
69
|
+
.replaceAll('-----END PRIVATE KEY-----', '')
|
|
70
|
+
.replaceAll(/\s+/g, '');
|
|
71
|
+
return Buffer.from(base64, 'base64');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert buffer to base64url with padding (for attestation params)
|
|
76
|
+
* @param {Buffer} buf
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function toBase64Url(buf) {
|
|
80
|
+
return Buffer.from(buf).toString('base64').replaceAll('+', '-').replaceAll('/', '_');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Convert buffer to standard base64
|
|
85
|
+
* @param {Buffer} buf
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
function toBase64(buf) {
|
|
89
|
+
return Buffer.from(buf).toString('base64');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate a random nonce for attestation
|
|
94
|
+
* @param {number} [length=16] - Nonce length in bytes
|
|
95
|
+
* @returns {Buffer}
|
|
96
|
+
*/
|
|
97
|
+
function generateNonce(length = 16) {
|
|
98
|
+
return crypto.randomBytes(length);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
// ATTESTATION PARAMS PREPARATION
|
|
103
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Prepare attestation request parameters
|
|
107
|
+
* @param {string} publicKeyPem - PEM-encoded public key
|
|
108
|
+
* @param {string} [userData=''] - Optional user data
|
|
109
|
+
* @returns {{ publicKey: string, userData: string, nonce: string }}
|
|
110
|
+
*/
|
|
111
|
+
function prepareAttestationParams(publicKeyPem, userData = '') {
|
|
112
|
+
const publicKeyDer = pemToDerPublic(publicKeyPem);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
publicKey: toBase64Url(publicKeyDer),
|
|
116
|
+
userData: userData ? toBase64Url(Buffer.from(userData, 'utf8')) : '',
|
|
117
|
+
nonce: toBase64(generateNonce(16))
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
generateEphemeralKeyPair,
|
|
123
|
+
pemToDerPublic,
|
|
124
|
+
pemToDerPrivate,
|
|
125
|
+
toBase64Url,
|
|
126
|
+
toBase64,
|
|
127
|
+
generateNonce,
|
|
128
|
+
prepareAttestationParams
|
|
129
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @faizahmed/secret-keystore - Runtime config loader
|
|
3
|
+
*
|
|
4
|
+
* Zero-config loader that discovers and cascades .env files, decrypts their
|
|
5
|
+
* KMS-encrypted (ENC[...]) values, and loads everything into an in-memory
|
|
6
|
+
* SecretKeyStore.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: decrypted values live ONLY in the returned SecretKeyStore's memory.
|
|
9
|
+
* They are never written to disk and — unless populateProcessEnv is explicitly
|
|
10
|
+
* enabled — never placed in process.env. This is deliberate: putting secrets in
|
|
11
|
+
* process.env widens the RCE blast radius (an attacker with code execution can
|
|
12
|
+
* dump them with `env`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const { parseEnvContent } = require('./content-operations');
|
|
19
|
+
const { createSecretKeyStore } = require('./keystore');
|
|
20
|
+
const { validateKmsKeyId } = require('./options');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the ordered list of .env files to load.
|
|
24
|
+
*
|
|
25
|
+
* Cascade order (later overrides earlier):
|
|
26
|
+
* .env → .env.local → .env.<NODE_ENV> → .env.<NODE_ENV>.local
|
|
27
|
+
*
|
|
28
|
+
* If an explicit `path` is given, only those file(s) are used (in order).
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} options
|
|
31
|
+
* @param {string} [options.cwd=process.cwd()] - Base directory
|
|
32
|
+
* @param {string|string[]} [options.path] - Explicit file path(s)
|
|
33
|
+
* @param {string} [options.nodeEnv] - Environment name for the cascade
|
|
34
|
+
* @returns {string[]} Absolute paths of files that exist, in load order
|
|
35
|
+
*/
|
|
36
|
+
function resolveEnvFiles({ cwd = process.cwd(), path: explicitPath, nodeEnv } = {}) {
|
|
37
|
+
if (explicitPath) {
|
|
38
|
+
const list = Array.isArray(explicitPath) ? explicitPath : [explicitPath];
|
|
39
|
+
return list.map(p => path.resolve(cwd, p)).filter(f => fs.existsSync(f));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const names = ['.env', '.env.local'];
|
|
43
|
+
if (nodeEnv) {
|
|
44
|
+
names.push(`.env.${nodeEnv}`, `.env.${nodeEnv}.local`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return names.map(n => path.resolve(cwd, n)).filter(f => fs.existsSync(f));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Merge .env files into a single key→value map (later files win).
|
|
52
|
+
* Values may still be encrypted (ENC[...]) at this stage.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} files - Absolute file paths, in load order
|
|
55
|
+
* @returns {{ merged: Record<string, string>, used: string[] }}
|
|
56
|
+
*/
|
|
57
|
+
function mergeEnvFiles(files) {
|
|
58
|
+
const merged = {};
|
|
59
|
+
const used = [];
|
|
60
|
+
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
if (!fs.existsSync(file)) continue;
|
|
63
|
+
|
|
64
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
65
|
+
for (const entry of parseEnvContent(content)) {
|
|
66
|
+
if (entry.type === 'keyvalue') {
|
|
67
|
+
merged[entry.key] = entry.value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
used.push(file);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { merged, used };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load and cascade .env files into an in-memory SecretKeyStore.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} options
|
|
80
|
+
* @param {string} options.kmsKeyId - REQUIRED KMS Key ID (explicit; no env fallback)
|
|
81
|
+
* @param {string} [options.cwd=process.cwd()] - Base directory for discovery
|
|
82
|
+
* @param {string|string[]} [options.path] - Explicit file path(s) (skips cascade)
|
|
83
|
+
* @param {string} [options.nodeEnv=process.env.NODE_ENV] - Environment for the cascade
|
|
84
|
+
* @param {boolean} [options.populateProcessEnv=false] - Opt-in: also copy decrypted
|
|
85
|
+
* values into process.env (override). Discouraged — widens RCE blast radius.
|
|
86
|
+
* @param {Object} [options.processEnv=process.env] - Target object for populateProcessEnv
|
|
87
|
+
* @param {Object} [options.logger] - Logger instance
|
|
88
|
+
* @returns {Promise<import('./keystore').SecretKeyStore>} Initialized in-memory store
|
|
89
|
+
*/
|
|
90
|
+
async function config(options = {}) {
|
|
91
|
+
const {
|
|
92
|
+
kmsKeyId,
|
|
93
|
+
cwd = process.cwd(),
|
|
94
|
+
path: explicitPath,
|
|
95
|
+
nodeEnv = process.env.NODE_ENV,
|
|
96
|
+
populateProcessEnv = false,
|
|
97
|
+
processEnv = process.env,
|
|
98
|
+
logger,
|
|
99
|
+
...keystoreOptions
|
|
100
|
+
} = options;
|
|
101
|
+
|
|
102
|
+
// Explicit-only Key ID: throws ValidationError if missing/invalid.
|
|
103
|
+
validateKmsKeyId(kmsKeyId);
|
|
104
|
+
|
|
105
|
+
const files = resolveEnvFiles({ cwd, path: explicitPath, nodeEnv });
|
|
106
|
+
const { merged, used } = mergeEnvFiles(files);
|
|
107
|
+
|
|
108
|
+
const names = used.map(f => path.basename(f)).join(', ') || '(none)';
|
|
109
|
+
logger?.info?.(`[config] Loaded ${used.length} env file(s): ${names}`);
|
|
110
|
+
|
|
111
|
+
const store = await createSecretKeyStore({ type: 'values', values: merged }, kmsKeyId, {
|
|
112
|
+
...keystoreOptions,
|
|
113
|
+
logger
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (populateProcessEnv) {
|
|
117
|
+
logger?.warn?.(
|
|
118
|
+
'[config] populateProcessEnv=true: decrypted secrets are being copied into ' +
|
|
119
|
+
'process.env (override). This widens the RCE blast radius (e.g. `env` dumps ' +
|
|
120
|
+
'them). Prefer reading from the returned in-memory store.'
|
|
121
|
+
);
|
|
122
|
+
for (const [key, value] of Object.entries(store.getAll())) {
|
|
123
|
+
processEnv[key] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return store;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { config, resolveEnvFiles, mergeEnvFiles };
|