@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,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 };