@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/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@faizahmed/secret-keystore",
3
+ "version": "1.1.0",
4
+ "description": "Secure secrets management with AWS KMS encryption, in-memory keystore, and Nitro Enclave attestation support",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "bin": {
8
+ "secret-keystore": "bin/cli.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "packageManager": "pnpm@9.15.9",
14
+ "files": [
15
+ "src/**/*.js",
16
+ "src/**/*.d.ts",
17
+ "bin/**/*.js",
18
+ "README.md",
19
+ "SECURITY.md",
20
+ "LICENSE"
21
+ ],
22
+ "dependencies": {
23
+ "@aws-sdk/client-kms": "^3.1067.0",
24
+ "asn1js": "^3.0.10",
25
+ "pkijs": "^3.4.0",
26
+ "pvtsutils": "^1.3.5"
27
+ },
28
+ "peerDependencies": {
29
+ "@aws-sdk/client-kms": "^3.x",
30
+ "js-yaml": "^4.x"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "js-yaml": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "scripts": {
38
+ "test": "node --test test/*.test.js",
39
+ "test:coverage": "c8 node --test test/*.test.js",
40
+ "lint": "eslint .",
41
+ "lint:fix": "eslint . --fix",
42
+ "format": "prettier --write \"{src,bin,test}/**/*.js\"",
43
+ "format:check": "prettier --check \"{src,bin,test}/**/*.js\"",
44
+ "typecheck": "tsc --noEmit --strict --lib es2022 src/index.d.ts",
45
+ "pack:local": "pnpm pack"
46
+ },
47
+ "devDependencies": {
48
+ "@eslint/js": "^9.0.0",
49
+ "aws-sdk-client-mock": "^4.1.0",
50
+ "c8": "^10.1.0",
51
+ "eslint": "^9.0.0",
52
+ "globals": "^15.0.0",
53
+ "prettier": "^3.3.0",
54
+ "typescript": "^5.5.0"
55
+ },
56
+ "keywords": [
57
+ "kms",
58
+ "secrets",
59
+ "keystore",
60
+ "aws",
61
+ "encryption",
62
+ "decryption",
63
+ "dotenv",
64
+ "nitro",
65
+ "attestation"
66
+ ],
67
+ "author": "Faiz Ahmed Farooqui",
68
+ "license": "MIT",
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "https://github.com/faizahmedfarooqui/secret-keystore.git"
72
+ },
73
+ "homepage": "https://github.com/faizahmedfarooqui/secret-keystore#readme",
74
+ "bugs": {
75
+ "url": "https://github.com/faizahmedfarooqui/secret-keystore/issues"
76
+ }
77
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - Attestation Client
3
+ *
4
+ * Fetches attestation documents from AWS Nitro Enclaves or Anjuna endpoints.
5
+ * The attestation document contains the caller's public key and is signed
6
+ * by the enclave's PCR values.
7
+ */
8
+
9
+ const https = require('node:https');
10
+ const http = require('node:http');
11
+ const { URL } = require('node:url');
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // ATTESTATION CLIENT
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+
17
+ /**
18
+ * Default attestation endpoint (Anjuna)
19
+ */
20
+ const DEFAULT_ATTESTATION_ENDPOINT = 'http://localhost:50123/api/v1/attestation/report';
21
+
22
+ /**
23
+ * Fetch attestation document from Nitro/Anjuna endpoint
24
+ *
25
+ * @param {Object} params - Attestation request parameters
26
+ * @param {string} params.publicKey - Base64url-encoded DER public key
27
+ * @param {string} [params.userData] - Base64url-encoded user data
28
+ * @param {string} [params.nonce] - Base64-encoded nonce
29
+ * @param {Object} [options] - Client options
30
+ * @param {string} [options.endpoint] - Attestation endpoint URL
31
+ * @param {number} [options.timeout] - Request timeout in milliseconds (default: 10000)
32
+ * @returns {Promise<{ attestationDocument: string }>} - Base64-encoded attestation document
33
+ * @throws {Error} If the request fails
34
+ */
35
+ async function fetchAttestationDocument(params, options = {}) {
36
+ const endpoint = options.endpoint || DEFAULT_ATTESTATION_ENDPOINT;
37
+ const timeout = options.timeout || 10000;
38
+
39
+ // Build URL with query parameters
40
+ const url = new URL(endpoint);
41
+ if (params.publicKey) {
42
+ url.searchParams.set('public_key', params.publicKey);
43
+ }
44
+ if (params.userData) {
45
+ url.searchParams.set('user_data', params.userData);
46
+ }
47
+ if (params.nonce) {
48
+ url.searchParams.set('nonce', params.nonce);
49
+ }
50
+
51
+ // Choose http or https based on protocol
52
+ const client = url.protocol === 'https:' ? https : http;
53
+
54
+ return new Promise((resolve, reject) => {
55
+ const req = client.get(
56
+ url.toString(),
57
+ {
58
+ timeout,
59
+ headers: {
60
+ Accept: 'application/octet-stream'
61
+ }
62
+ },
63
+ res => {
64
+ const chunks = [];
65
+
66
+ res.on('data', chunk => chunks.push(chunk));
67
+
68
+ res.on('end', () => {
69
+ const buffer = Buffer.concat(chunks);
70
+
71
+ if (res.statusCode !== 200) {
72
+ const errorMessage = buffer.toString('utf8');
73
+ reject(
74
+ new Error(
75
+ `Attestation endpoint returned ${res.statusCode}: ${errorMessage}`
76
+ )
77
+ );
78
+ return;
79
+ }
80
+
81
+ // The response is binary attestation document, convert to base64
82
+ const attestationDocument = buffer.toString('base64');
83
+
84
+ resolve({ attestationDocument });
85
+ });
86
+ }
87
+ );
88
+
89
+ req.on('error', error => {
90
+ reject(new Error(`Attestation request failed: ${error.message}`));
91
+ });
92
+
93
+ req.on('timeout', () => {
94
+ req.destroy();
95
+ reject(new Error(`Attestation request timed out after ${timeout}ms`));
96
+ });
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Check if running inside a Nitro Enclave
102
+ * Nitro Enclaves have /dev/nsm device
103
+ *
104
+ * @returns {boolean}
105
+ */
106
+ function isNitroEnclave() {
107
+ try {
108
+ const fs = require('node:fs');
109
+ return fs.existsSync('/dev/nsm');
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Check if attestation endpoint is reachable
117
+ *
118
+ * @param {string} [endpoint] - Attestation endpoint URL
119
+ * @param {number} [timeout] - Request timeout in milliseconds
120
+ * @returns {Promise<boolean>}
121
+ */
122
+ async function isAttestationAvailable(endpoint = DEFAULT_ATTESTATION_ENDPOINT, timeout = 3000) {
123
+ const url = new URL(endpoint);
124
+ const client = url.protocol === 'https:' ? https : http;
125
+
126
+ return new Promise(resolve => {
127
+ const req = client.get(url.toString(), { timeout }, res => {
128
+ // Any response means the endpoint is available
129
+ res.resume(); // Consume response data to free up memory
130
+ resolve(true);
131
+ });
132
+
133
+ req.on('error', () => resolve(false));
134
+ req.on('timeout', () => {
135
+ req.destroy();
136
+ resolve(false);
137
+ });
138
+ });
139
+ }
140
+
141
+ module.exports = {
142
+ fetchAttestationDocument,
143
+ isNitroEnclave,
144
+ isAttestationAvailable,
145
+ DEFAULT_ATTESTATION_ENDPOINT
146
+ };
@@ -0,0 +1,339 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - Attestation Manager
3
+ *
4
+ * Manages the attestation document lifecycle:
5
+ * - Generates ephemeral RSA key pairs
6
+ * - Fetches attestation documents from Nitro/Anjuna
7
+ * - Caches attestation materials
8
+ * - Handles 5-minute age limit by refreshing on demand
9
+ * - Provides CMS unwrapping for KMS CiphertextForRecipient
10
+ */
11
+
12
+ const { DecryptCommand } = require('@aws-sdk/client-kms');
13
+ const { generateEphemeralKeyPair, prepareAttestationParams } = require('./key-pair');
14
+ const { fetchAttestationDocument, DEFAULT_ATTESTATION_ENDPOINT } = require('./attestation-client');
15
+ const { unwrapCms } = require('./cms-unwrap');
16
+
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+ // ATTESTATION MANAGER CLASS
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+
21
+ /**
22
+ * AttestationManager handles the full attestation lifecycle
23
+ */
24
+ class AttestationManager {
25
+ constructor(options = {}) {
26
+ this.endpoint = options.endpoint || DEFAULT_ATTESTATION_ENDPOINT;
27
+ this.timeout = options.timeout || 10000;
28
+ this.userData = options.userData || '';
29
+ this.logger = options.logger || null;
30
+
31
+ // Cached materials
32
+ this.cachedKeyPair = null;
33
+ this.cachedDocument = null;
34
+ this.cachedTimestamp = null;
35
+
36
+ // Initialization state
37
+ this.initialized = false;
38
+ this.initError = null;
39
+
40
+ // Mutex for concurrent initialization
41
+ this.initPromise = null;
42
+ }
43
+
44
+ /**
45
+ * Initialize attestation (generate key pair, fetch document)
46
+ * @returns {Promise<void>}
47
+ */
48
+ async initialize() {
49
+ // Prevent concurrent initialization
50
+ if (this.initPromise) {
51
+ return this.initPromise;
52
+ }
53
+
54
+ this.initPromise = this._doInitialize();
55
+ try {
56
+ await this.initPromise;
57
+ } finally {
58
+ this.initPromise = null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Internal initialization logic
64
+ * @private
65
+ */
66
+ async _doInitialize() {
67
+ this._log('debug', 'Initializing attestation...');
68
+ this.initialized = false;
69
+ this.initError = null;
70
+
71
+ try {
72
+ // 1. Generate ephemeral RSA-4096 key pair
73
+ this._log('debug', 'Generating ephemeral RSA-4096 key pair...');
74
+ this.cachedKeyPair = generateEphemeralKeyPair();
75
+
76
+ // 2. Prepare attestation request parameters
77
+ const params = prepareAttestationParams(this.cachedKeyPair.publicKey, this.userData);
78
+
79
+ // 3. Fetch attestation document
80
+ this._log('debug', `Fetching attestation document from ${this.endpoint}...`);
81
+ const response = await fetchAttestationDocument(params, {
82
+ endpoint: this.endpoint,
83
+ timeout: this.timeout
84
+ });
85
+
86
+ // 4. Cache the document
87
+ this.cachedDocument = response.attestationDocument;
88
+ this.cachedTimestamp = Date.now();
89
+ this.initialized = true;
90
+
91
+ this._log('info', 'Attestation initialized successfully');
92
+ } catch (error) {
93
+ this.initError = error.message;
94
+ this.initialized = false;
95
+ this._log('error', `Attestation initialization failed: ${error.message}`);
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Reinitialize attestation (for 5-minute refresh)
102
+ * @returns {Promise<void>}
103
+ */
104
+ async reinitialize() {
105
+ this._log('warn', 'Reinitializing attestation (5-minute refresh)...');
106
+ // Clear current cache
107
+ this.cachedKeyPair = null;
108
+ this.cachedDocument = null;
109
+ this.cachedTimestamp = null;
110
+ this.initialized = false;
111
+
112
+ // Re-initialize
113
+ await this.initialize();
114
+ }
115
+
116
+ /**
117
+ * Check if attestation is initialized and usable
118
+ * @returns {boolean}
119
+ */
120
+ isInitialized() {
121
+ return this.initialized && !!this.cachedDocument && !!this.cachedKeyPair;
122
+ }
123
+
124
+ /**
125
+ * Get cached attestation materials
126
+ * @returns {{ document: string, privateKey: string, publicKey: string } | null}
127
+ */
128
+ getCachedMaterials() {
129
+ if (!this.isInitialized()) {
130
+ return null;
131
+ }
132
+
133
+ return {
134
+ document: this.cachedDocument,
135
+ privateKey: this.cachedKeyPair.privateKey,
136
+ publicKey: this.cachedKeyPair.publicKey
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Get the age of the cached attestation document in milliseconds
142
+ * @returns {number | null}
143
+ */
144
+ getDocumentAge() {
145
+ if (!this.cachedTimestamp) {
146
+ return null;
147
+ }
148
+ return Date.now() - this.cachedTimestamp;
149
+ }
150
+
151
+ /**
152
+ * Perform attested decrypt with KMS
153
+ *
154
+ * This method:
155
+ * 1. Ensures attestation is initialized
156
+ * 2. Calls KMS Decrypt with Recipient parameter
157
+ * 3. Handles CiphertextForRecipient by unwrapping with PKIjs
158
+ * 4. Retries on 5-minute age limit error
159
+ *
160
+ * @param {import('@aws-sdk/client-kms').KMSClient} kmsClient - KMS client
161
+ * @param {Buffer} ciphertextBlob - The encrypted data
162
+ * @param {string} kmsKeyId - The KMS key ID
163
+ * @param {Object} [options] - Additional options
164
+ * @param {string} [options.encryptionAlgorithm] - 'RSAES_OAEP_SHA_256' or 'SYMMETRIC_DEFAULT'
165
+ * @param {Object} [options.encryptionContext] - KMS encryption context
166
+ * @returns {Promise<Buffer>} - Decrypted plaintext
167
+ */
168
+ async decryptWithAttestation(kmsClient, ciphertextBlob, kmsKeyId, options = {}) {
169
+ // Ensure initialized
170
+ if (!this.isInitialized()) {
171
+ await this.initialize();
172
+ }
173
+
174
+ const materials = this.getCachedMaterials();
175
+ if (!materials) {
176
+ throw new Error('Failed to get attestation materials after initialization');
177
+ }
178
+
179
+ try {
180
+ return await this._performAttestedDecrypt(
181
+ kmsClient,
182
+ ciphertextBlob,
183
+ kmsKeyId,
184
+ materials,
185
+ options
186
+ );
187
+ } catch (error) {
188
+ // Check for 5-minute age limit error
189
+ if (this._isAgeLimitError(error)) {
190
+ this._log('warn', 'Attestation document expired (5-minute limit), refreshing...');
191
+
192
+ // Reinitialize to get fresh materials
193
+ await this.reinitialize();
194
+
195
+ const freshMaterials = this.getCachedMaterials();
196
+ if (!freshMaterials) {
197
+ throw new Error('Failed to get fresh attestation materials');
198
+ }
199
+
200
+ // Retry once with fresh materials
201
+ return await this._performAttestedDecrypt(
202
+ kmsClient,
203
+ ciphertextBlob,
204
+ kmsKeyId,
205
+ freshMaterials,
206
+ options
207
+ );
208
+ }
209
+
210
+ throw error;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Perform the actual attested decrypt
216
+ * @private
217
+ */
218
+ async _performAttestedDecrypt(kmsClient, ciphertextBlob, kmsKeyId, materials, options) {
219
+ const attestationBuffer = Buffer.from(materials.document, 'base64');
220
+
221
+ // Build KMS Decrypt command with Recipient
222
+ const command = new DecryptCommand({
223
+ KeyId: kmsKeyId,
224
+ CiphertextBlob: ciphertextBlob,
225
+ EncryptionAlgorithm: options.encryptionAlgorithm,
226
+ EncryptionContext: options.encryptionContext,
227
+ Recipient: {
228
+ KeyEncryptionAlgorithm: 'RSAES_OAEP_SHA_256',
229
+ AttestationDocument: attestationBuffer
230
+ }
231
+ });
232
+
233
+ this._log('debug', 'Sending KMS Decrypt with Recipient...');
234
+ const response = await kmsClient.send(command);
235
+
236
+ // With Recipient, KMS returns CiphertextForRecipient, NOT Plaintext
237
+ if (!response.CiphertextForRecipient) {
238
+ throw new Error(
239
+ 'KMS did not return CiphertextForRecipient - check key policy and Recipient support'
240
+ );
241
+ }
242
+
243
+ const ciphertextForRecipient = Buffer.from(response.CiphertextForRecipient);
244
+ this._log(
245
+ 'debug',
246
+ `Received CiphertextForRecipient (${ciphertextForRecipient.length} bytes)`
247
+ );
248
+
249
+ // Unwrap CMS EnvelopedData using our ephemeral private key
250
+ this._log('debug', 'Unwrapping CMS EnvelopedData...');
251
+ const plaintext = await unwrapCms(ciphertextForRecipient, materials.privateKey);
252
+ this._log('debug', `Decrypted plaintext (${plaintext.length} bytes)`);
253
+
254
+ return plaintext;
255
+ }
256
+
257
+ /**
258
+ * Check if an error is a 5-minute age limit error
259
+ * @private
260
+ */
261
+ _isAgeLimitError(error) {
262
+ const message = error.message || '';
263
+ return (
264
+ message.includes('exceeded the five-minute age limit') ||
265
+ message.includes('exceeded the five minute age limit') ||
266
+ message.includes('age limit') ||
267
+ message.includes('cannot parse the attestation document') ||
268
+ message.includes('cannot parse attestation document')
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Log a message
274
+ * @private
275
+ */
276
+ _log(level, message) {
277
+ if (this.logger && typeof this.logger[level] === 'function') {
278
+ this.logger[level](`[AttestationManager] ${message}`);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Get initialization status
284
+ * @returns {Object}
285
+ */
286
+ getStatus() {
287
+ return {
288
+ initialized: this.initialized,
289
+ hasError: !!this.initError,
290
+ error: this.initError,
291
+ hasDocument: !!this.cachedDocument,
292
+ hasKeyPair: !!this.cachedKeyPair,
293
+ documentAge: this.getDocumentAge(),
294
+ endpoint: this.endpoint
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Destroy the manager and clear all cached materials
300
+ */
301
+ destroy() {
302
+ this.cachedKeyPair = null;
303
+ this.cachedDocument = null;
304
+ this.cachedTimestamp = null;
305
+ this.initialized = false;
306
+ this.initError = null;
307
+ this._log('debug', 'Attestation manager destroyed');
308
+ }
309
+ }
310
+
311
+ // ═══════════════════════════════════════════════════════════════════════════
312
+ // FACTORY FUNCTION
313
+ // ═══════════════════════════════════════════════════════════════════════════
314
+
315
+ /**
316
+ * Create and initialize an AttestationManager
317
+ *
318
+ * @param {Object} [options] - Options
319
+ * @param {string} [options.endpoint] - Attestation endpoint URL
320
+ * @param {number} [options.timeout] - Request timeout in ms
321
+ * @param {string} [options.userData] - User data to include in attestation
322
+ * @param {Object} [options.logger] - Logger instance
323
+ * @param {boolean} [options.autoInitialize=true] - Initialize on creation
324
+ * @returns {Promise<AttestationManager>}
325
+ */
326
+ async function createAttestationManager(options = {}) {
327
+ const manager = new AttestationManager(options);
328
+
329
+ if (options.autoInitialize !== false) {
330
+ await manager.initialize();
331
+ }
332
+
333
+ return manager;
334
+ }
335
+
336
+ module.exports = {
337
+ AttestationManager,
338
+ createAttestationManager
339
+ };