@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,319 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - Path Matching
3
+ *
4
+ * Utilities for matching paths in nested objects using ** patterns.
5
+ * Only ** (any-depth) pattern is supported, not * (single-level).
6
+ */
7
+
8
+ const { PathError, PATH_ERROR_CODES } = require('./errors');
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // PATH UTILITIES
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Get a value from an object by dot-notation path
16
+ * @param {Object} obj - Object to get value from
17
+ * @param {string} path - Dot-notation path (e.g., 'a.b.c')
18
+ * @returns {*} Value at path or undefined
19
+ */
20
+ function getByPath(obj, path) {
21
+ if (!path) return obj;
22
+
23
+ const parts = path.split('.');
24
+ let current = obj;
25
+
26
+ for (const part of parts) {
27
+ if (current === null || current === undefined) {
28
+ return undefined;
29
+ }
30
+ current = current[part];
31
+ }
32
+
33
+ return current;
34
+ }
35
+
36
+ /**
37
+ * Set a value in an object by dot-notation path
38
+ * @param {Object} obj - Object to set value in
39
+ * @param {string} path - Dot-notation path (e.g., 'a.b.c')
40
+ * @param {*} value - Value to set
41
+ * @returns {Object} Modified object
42
+ */
43
+ function setByPath(obj, path, value) {
44
+ if (!path) return value;
45
+
46
+ const parts = path.split('.');
47
+ let current = obj;
48
+
49
+ for (let i = 0; i < parts.length - 1; i++) {
50
+ const part = parts[i];
51
+ if (current[part] === undefined || current[part] === null) {
52
+ current[part] = {};
53
+ }
54
+ current = current[part];
55
+ }
56
+
57
+ current[parts[parts.length - 1]] = value;
58
+ return obj;
59
+ }
60
+
61
+ /**
62
+ * Get all paths in an object (leaf nodes only)
63
+ * @param {Object} obj - Object to traverse
64
+ * @param {string} [prefix=''] - Current path prefix
65
+ * @returns {string[]} Array of dot-notation paths
66
+ */
67
+ function getAllPaths(obj, prefix = '') {
68
+ const paths = [];
69
+
70
+ if (obj === null || obj === undefined) {
71
+ return paths;
72
+ }
73
+
74
+ if (typeof obj !== 'object' || Array.isArray(obj) || Buffer.isBuffer(obj)) {
75
+ // Leaf node
76
+ if (prefix) {
77
+ paths.push(prefix);
78
+ }
79
+ return paths;
80
+ }
81
+
82
+ for (const [key, value] of Object.entries(obj)) {
83
+ const currentPath = prefix ? `${prefix}.${key}` : key;
84
+
85
+ if (
86
+ value !== null &&
87
+ typeof value === 'object' &&
88
+ !Array.isArray(value) &&
89
+ !Buffer.isBuffer(value)
90
+ ) {
91
+ // Recurse into nested object
92
+ paths.push(...getAllPaths(value, currentPath));
93
+ } else {
94
+ // Leaf node
95
+ paths.push(currentPath);
96
+ }
97
+ }
98
+
99
+ return paths;
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════
103
+ // PATTERN MATCHING (** only)
104
+ // ═══════════════════════════════════════════════════════════════════════════
105
+
106
+ /**
107
+ * Convert a ** pattern to a regex
108
+ * @param {string} pattern - Pattern with ** wildcards
109
+ * @returns {RegExp}
110
+ */
111
+ function patternToRegex(pattern) {
112
+ // Escape special regex characters except **
113
+ let escaped = pattern
114
+ .replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`) // Escape special chars
115
+ .replaceAll('**', '{{DOUBLE_STAR}}'); // Temporarily replace **
116
+
117
+ // Replace ** with regex pattern (match any depth)
118
+ escaped = escaped.replaceAll('{{DOUBLE_STAR}}', '.*');
119
+
120
+ // Anchor the pattern
121
+ return new RegExp(`^${escaped}$`);
122
+ }
123
+
124
+ /**
125
+ * Check if a path matches a ** pattern
126
+ * @param {string} path - Dot-notation path
127
+ * @param {string} pattern - Pattern with ** wildcards
128
+ * @returns {boolean}
129
+ */
130
+ function matchesPattern(path, pattern) {
131
+ // Handle patterns that start with **
132
+ if (pattern.startsWith('**.')) {
133
+ // **.foo matches any path ending with .foo or just foo
134
+ const suffix = pattern.slice(3);
135
+ if (path === suffix) return true;
136
+ if (path.endsWith('.' + suffix)) return true;
137
+ return false;
138
+ }
139
+
140
+ // Handle patterns that end with **
141
+ if (pattern.endsWith('.**')) {
142
+ // foo.** matches foo and any path starting with foo.
143
+ const prefix = pattern.slice(0, -3);
144
+ if (path === prefix) return true;
145
+ if (path.startsWith(prefix + '.')) return true;
146
+ return false;
147
+ }
148
+
149
+ // Handle patterns with ** in the middle or complex patterns
150
+ const regex = patternToRegex(pattern);
151
+ return regex.test(path);
152
+ }
153
+
154
+ /**
155
+ * Add explicit paths to selected set
156
+ * @private
157
+ */
158
+ function addExplicitPaths(selectedPaths, allPaths, paths) {
159
+ if (!paths || !Array.isArray(paths)) return;
160
+
161
+ for (const path of paths) {
162
+ if (allPaths.includes(path)) {
163
+ selectedPaths.add(path);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Add pattern-matched paths to selected set
170
+ * @private
171
+ */
172
+ function addPatternMatches(selectedPaths, allPaths, patterns) {
173
+ if (!patterns || !Array.isArray(patterns)) return;
174
+
175
+ for (const pattern of patterns) {
176
+ for (const path of allPaths) {
177
+ if (matchesPattern(path, pattern)) {
178
+ selectedPaths.add(path);
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Filter paths using patterns and explicit paths
186
+ * @param {string[]} allPaths - All available paths
187
+ * @param {Object} options - Selection options
188
+ * @param {string[]} [options.paths] - Explicit paths to include
189
+ * @param {string[]} [options.patterns] - Patterns to match
190
+ * @param {Object} [options.exclude] - Exclusions
191
+ * @param {string[]} [options.exclude.paths] - Explicit paths to exclude
192
+ * @param {string[]} [options.exclude.patterns] - Patterns to exclude
193
+ * @returns {string[]} Matching paths
194
+ */
195
+ function filterPaths(allPaths, options = {}) {
196
+ const { paths, patterns, exclude } = options;
197
+
198
+ // If no selection criteria, return all paths
199
+ if (!paths && !patterns) {
200
+ return excludePaths(allPaths, exclude);
201
+ }
202
+
203
+ const selectedPaths = new Set();
204
+
205
+ addExplicitPaths(selectedPaths, allPaths, paths);
206
+ addPatternMatches(selectedPaths, allPaths, patterns);
207
+
208
+ return excludePaths(Array.from(selectedPaths), exclude);
209
+ }
210
+
211
+ /**
212
+ * Exclude paths based on exclusion options
213
+ * @param {string[]} paths - Paths to filter
214
+ * @param {Object} [exclude] - Exclusion options
215
+ * @returns {string[]}
216
+ */
217
+ function excludePaths(paths, exclude) {
218
+ if (!exclude) return paths;
219
+
220
+ return paths.filter(path => {
221
+ // Check explicit exclusions
222
+ if (exclude.paths && exclude.paths.includes(path)) {
223
+ return false;
224
+ }
225
+
226
+ // Check pattern exclusions
227
+ if (exclude.patterns) {
228
+ for (const pattern of exclude.patterns) {
229
+ if (matchesPattern(path, pattern)) {
230
+ return false;
231
+ }
232
+ }
233
+ }
234
+
235
+ return true;
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Validate that all explicit paths exist
241
+ * @param {string[]} requestedPaths - Requested paths
242
+ * @param {string[]} existingPaths - Paths that exist
243
+ * @throws {PathError}
244
+ */
245
+ function validatePathsExist(requestedPaths, existingPaths) {
246
+ for (const path of requestedPaths) {
247
+ if (!existingPaths.includes(path)) {
248
+ throw new PathError(`Path not found: ${path}`, PATH_ERROR_CODES.NOT_FOUND, path);
249
+ }
250
+ }
251
+ }
252
+
253
+ // ═══════════════════════════════════════════════════════════════════════════
254
+ // OBJECT TRANSFORMATION
255
+ // ═══════════════════════════════════════════════════════════════════════════
256
+
257
+ /**
258
+ * Transform values at selected paths in an object
259
+ * @param {Object} obj - Source object
260
+ * @param {string[]} paths - Paths to transform
261
+ * @param {Function} transformer - Async function (value, path) => newValue
262
+ * @param {Object} [options] - Options
263
+ * @param {boolean} [options.continueOnError=false] - Continue on transformation errors
264
+ * @returns {Promise<Object>} Result with transformed object
265
+ */
266
+ async function transformAtPaths(obj, paths, transformer, options = {}) {
267
+ const continueOnError = options.continueOnError === true;
268
+
269
+ const result = {
270
+ object: structuredClone(obj),
271
+ transformed: [],
272
+ skipped: [],
273
+ failed: []
274
+ };
275
+
276
+ for (const path of paths) {
277
+ const value = getByPath(obj, path);
278
+
279
+ if (value === undefined) {
280
+ result.skipped.push(path);
281
+ continue;
282
+ }
283
+
284
+ try {
285
+ const newValue = await transformer(value, path);
286
+ setByPath(result.object, path, newValue);
287
+ result.transformed.push(path);
288
+ } catch (error) {
289
+ if (continueOnError) {
290
+ result.failed.push({ path, error });
291
+ } else {
292
+ throw error;
293
+ }
294
+ }
295
+ }
296
+
297
+ return result;
298
+ }
299
+
300
+ // ═══════════════════════════════════════════════════════════════════════════
301
+ // EXPORTS
302
+ // ═══════════════════════════════════════════════════════════════════════════
303
+
304
+ module.exports = {
305
+ // Path utilities
306
+ getByPath,
307
+ setByPath,
308
+ getAllPaths,
309
+
310
+ // Pattern matching
311
+ patternToRegex,
312
+ matchesPattern,
313
+ filterPaths,
314
+ excludePaths,
315
+ validatePathsExist,
316
+
317
+ // Object transformation
318
+ transformAtPaths
319
+ };
package/src/rotate.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - Key Rotation
3
+ *
4
+ * Re-encrypt the already-encrypted values in a config file under a NEW KMS Key
5
+ * ID, decrypting them with the OLD key first. Values that were plaintext stay
6
+ * plaintext; only previously-encrypted entries are rotated.
7
+ *
8
+ * Decryption happens in memory; nothing plaintext is written to disk by this
9
+ * helper (the caller decides what to do with the returned content string).
10
+ */
11
+
12
+ const { encryptKMSEnvContent, decryptKMSEnvContent } = require('./content-operations');
13
+ const { encryptKMSObject, decryptKMSObject } = require('./object-operations');
14
+ const { getAllPaths, getByPath } = require('./path-matcher');
15
+ const { isAlreadyEncrypted } = require('./kms');
16
+ const { parseEnvContent } = require('./content-operations');
17
+ const { parseYaml, serializeYaml } = require('./yaml-utils');
18
+ const { ContentError, CONTENT_ERROR_CODES } = require('./errors');
19
+
20
+ /**
21
+ * Find the keys/paths in a parsed config that are currently encrypted.
22
+ * @private
23
+ */
24
+ function findEncryptedEnvKeys(content) {
25
+ return parseEnvContent(content)
26
+ .filter(entry => entry.type === 'keyvalue' && isAlreadyEncrypted(entry.value))
27
+ .map(entry => entry.key);
28
+ }
29
+
30
+ function findEncryptedObjectPaths(obj) {
31
+ return getAllPaths(obj).filter(path => {
32
+ const value = getByPath(obj, path);
33
+ return typeof value === 'string' && isAlreadyEncrypted(value);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Rotate encrypted values from oldKmsKeyId to newKmsKeyId.
39
+ *
40
+ * @param {string} content - Raw file content
41
+ * @param {'env'|'json'|'yaml'} format - Content format
42
+ * @param {string} oldKmsKeyId - KMS Key ID the content is currently encrypted with
43
+ * @param {string} newKmsKeyId - KMS Key ID to re-encrypt with
44
+ * @param {Object} [options] - Forwarded encrypt/decrypt options (aws, logger, etc.)
45
+ * @returns {Promise<{ content: string, rotated: string[] }>}
46
+ */
47
+ async function rotateKMSContent(content, format, oldKmsKeyId, newKmsKeyId, options = {}) {
48
+ if (format === 'env') {
49
+ const encryptedKeys = findEncryptedEnvKeys(content);
50
+ if (encryptedKeys.length === 0) {
51
+ return { content, rotated: [] };
52
+ }
53
+ const decrypted = await decryptKMSEnvContent(content, oldKmsKeyId, options);
54
+ const reEncrypted = await encryptKMSEnvContent(decrypted.content, newKmsKeyId, {
55
+ ...options,
56
+ paths: encryptedKeys
57
+ });
58
+ return { content: reEncrypted.content, rotated: reEncrypted.encrypted };
59
+ }
60
+
61
+ if (format === 'json' || format === 'yaml') {
62
+ const obj = format === 'json' ? JSON.parse(content) : parseYaml(content);
63
+ const encryptedPaths = findEncryptedObjectPaths(obj);
64
+ if (encryptedPaths.length === 0) {
65
+ return { content, rotated: [] };
66
+ }
67
+
68
+ const decrypted = await decryptKMSObject(obj, oldKmsKeyId, {
69
+ ...options,
70
+ paths: encryptedPaths
71
+ });
72
+ const reEncrypted = await encryptKMSObject(decrypted.object, newKmsKeyId, {
73
+ ...options,
74
+ paths: encryptedPaths
75
+ });
76
+
77
+ const output =
78
+ format === 'json'
79
+ ? JSON.stringify(reEncrypted.object, null, 2)
80
+ : serializeYaml(reEncrypted.object);
81
+
82
+ return { content: output, rotated: reEncrypted.encrypted };
83
+ }
84
+
85
+ throw new ContentError(
86
+ `Unsupported format for rotation: ${format}`,
87
+ CONTENT_ERROR_CODES.INVALID_FORMAT,
88
+ format
89
+ );
90
+ }
91
+
92
+ module.exports = { rotateKMSContent };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * @faizahmed/secret-keystore - YAML Utilities
3
+ *
4
+ * Handles YAML parsing/serialization with optional js-yaml dependency.
5
+ * Falls back to simple parser for basic YAML when js-yaml is not installed.
6
+ */
7
+
8
+ const { ContentError, CONTENT_ERROR_CODES } = require('./errors');
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // JS-YAML LOADER
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Cached reference to js-yaml module (or null if not available)
16
+ * @type {Object|null|undefined}
17
+ */
18
+ let jsYamlModule;
19
+ let jsYamlChecked = false;
20
+
21
+ /**
22
+ * Check if js-yaml is available
23
+ * @returns {boolean}
24
+ */
25
+ function isJsYamlAvailable() {
26
+ if (!jsYamlChecked) {
27
+ try {
28
+ jsYamlModule = require('js-yaml');
29
+ } catch {
30
+ jsYamlModule = null;
31
+ }
32
+ jsYamlChecked = true;
33
+ }
34
+ return jsYamlModule !== null;
35
+ }
36
+
37
+ /**
38
+ * Get the js-yaml module
39
+ * @returns {Object|null}
40
+ */
41
+ function getJsYaml() {
42
+ if (!jsYamlChecked) {
43
+ isJsYamlAvailable();
44
+ }
45
+ return jsYamlModule;
46
+ }
47
+
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // SIMPLE YAML PARSER (FALLBACK)
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+
52
+ /**
53
+ * Features NOT supported by simple parser:
54
+ * - Multi-line strings (| and >)
55
+ * - Anchors and aliases (&, *)
56
+ * - Complex nested arrays
57
+ * - Type tags (!!str, !!int, etc.)
58
+ * - Document separators (---, ...)
59
+ */
60
+ const COMPLEX_YAML_PATTERNS = [
61
+ /&\w+/, // Anchors (&anchor)
62
+ /\*\w+/, // Aliases (*anchor)
63
+ /<<:/, // Merge key
64
+ /:\s*[|>]/m, // Multi-line strings
65
+ /^---/m, // Document separator
66
+ /^\.\.\./m, // Document end
67
+ /!!\w+/, // Type tags
68
+ /^\s*-\s*[^#\n]+:/m // Nested objects in arrays
69
+ ];
70
+
71
+ /**
72
+ * Check if YAML content has complex features
73
+ * @param {string} content
74
+ * @returns {boolean}
75
+ */
76
+ function hasComplexYamlFeatures(content) {
77
+ return COMPLEX_YAML_PATTERNS.some(pattern => pattern.test(content));
78
+ }
79
+
80
+ /**
81
+ * Simple YAML parser (handles basic key: value structures)
82
+ * @param {string} content - YAML content
83
+ * @returns {Object} Parsed object
84
+ */
85
+ function parseYamlSimple(content) {
86
+ const lines = content.split('\n');
87
+ const result = {};
88
+ const stack = [{ obj: result, indent: -1 }];
89
+
90
+ for (const line of lines) {
91
+ // Skip empty lines and comments
92
+ if (!line.trim() || line.trim().startsWith('#')) continue;
93
+
94
+ const match = line.match(/^(\s*)([^:]+):\s*(.*)$/);
95
+ if (!match) continue;
96
+
97
+ const indent = match[1].length;
98
+ const key = match[2].trim();
99
+ let value = match[3].trim();
100
+
101
+ // Remove inline comments (not in quoted strings)
102
+ if (!value.startsWith('"') && !value.startsWith("'")) {
103
+ const commentIndex = value.indexOf('#');
104
+ if (commentIndex > 0) {
105
+ value = value.substring(0, commentIndex).trim();
106
+ }
107
+ }
108
+
109
+ // Remove quotes
110
+ if (
111
+ (value.startsWith('"') && value.endsWith('"')) ||
112
+ (value.startsWith("'") && value.endsWith("'"))
113
+ ) {
114
+ value = value.slice(1, -1);
115
+ }
116
+
117
+ // Pop stack until we find parent
118
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
119
+ stack.pop();
120
+ }
121
+
122
+ const parent = stack[stack.length - 1].obj;
123
+
124
+ if (value) {
125
+ parent[key] = value;
126
+ } else {
127
+ parent[key] = {};
128
+ stack.push({ obj: parent[key], indent });
129
+ }
130
+ }
131
+
132
+ return result;
133
+ }
134
+
135
+ /**
136
+ * Simple YAML serializer
137
+ * @param {Object} obj - Object to serialize
138
+ * @param {number} indent - Current indentation level
139
+ * @returns {string} YAML string
140
+ */
141
+ function serializeYamlSimple(obj, indent = 0) {
142
+ let result = '';
143
+ const spaces = ' '.repeat(indent);
144
+
145
+ for (const [key, value] of Object.entries(obj)) {
146
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
147
+ result += `${spaces}${key}:\n`;
148
+ result += serializeYamlSimple(value, indent + 1);
149
+ } else {
150
+ const needsQuotes =
151
+ typeof value === 'string' &&
152
+ (value.includes(':') ||
153
+ value.includes('#') ||
154
+ value.includes(' ') ||
155
+ value.includes('\n'));
156
+ const formattedValue = needsQuotes ? `"${value}"` : value;
157
+ result += `${spaces}${key}: ${formattedValue}\n`;
158
+ }
159
+ }
160
+
161
+ return result;
162
+ }
163
+
164
+ // ═══════════════════════════════════════════════════════════════════════════
165
+ // PUBLIC API
166
+ // ═══════════════════════════════════════════════════════════════════════════
167
+
168
+ /**
169
+ * Parse YAML content to object
170
+ *
171
+ * Uses js-yaml if available, falls back to simple parser for basic YAML.
172
+ * Throws an error if content has complex features and js-yaml is not installed.
173
+ *
174
+ * @param {string} content - YAML content string
175
+ * @returns {Object} Parsed object
176
+ * @throws {ContentError} If parsing fails or complex YAML without js-yaml
177
+ */
178
+ function parseYaml(content) {
179
+ const yaml = getJsYaml();
180
+
181
+ if (yaml) {
182
+ try {
183
+ return yaml.load(content);
184
+ } catch (error) {
185
+ throw new ContentError(
186
+ `Failed to parse YAML: ${error.message}`,
187
+ CONTENT_ERROR_CODES.PARSE_FAILED,
188
+ 'yaml',
189
+ error
190
+ );
191
+ }
192
+ }
193
+
194
+ // No js-yaml available - check for complex features
195
+ if (hasComplexYamlFeatures(content)) {
196
+ throw new ContentError(
197
+ 'YAML content has complex features (anchors, multi-line strings, etc.) that require js-yaml. ' +
198
+ 'Install js-yaml: npm install js-yaml',
199
+ CONTENT_ERROR_CODES.PARSE_FAILED,
200
+ 'yaml'
201
+ );
202
+ }
203
+
204
+ // Use simple parser for basic YAML
205
+ try {
206
+ return parseYamlSimple(content);
207
+ } catch (error) {
208
+ throw new ContentError(
209
+ `Failed to parse YAML: ${error.message}. For complex YAML, install js-yaml: npm install js-yaml`,
210
+ CONTENT_ERROR_CODES.PARSE_FAILED,
211
+ 'yaml',
212
+ error
213
+ );
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Serialize object to YAML string
219
+ *
220
+ * Uses js-yaml if available, falls back to simple serializer.
221
+ *
222
+ * @param {Object} obj - Object to serialize
223
+ * @returns {string} YAML string
224
+ */
225
+ function serializeYaml(obj) {
226
+ const yaml = getJsYaml();
227
+
228
+ if (yaml) {
229
+ try {
230
+ return yaml.dump(obj, { lineWidth: -1 });
231
+ } catch (error) {
232
+ throw new ContentError(
233
+ `Failed to serialize YAML: ${error.message}`,
234
+ CONTENT_ERROR_CODES.SERIALIZATION_FAILED,
235
+ 'yaml',
236
+ error
237
+ );
238
+ }
239
+ }
240
+
241
+ try {
242
+ return serializeYamlSimple(obj);
243
+ } catch (error) {
244
+ throw new ContentError(
245
+ `Failed to serialize YAML: ${error.message}`,
246
+ CONTENT_ERROR_CODES.SERIALIZATION_FAILED,
247
+ 'yaml',
248
+ error
249
+ );
250
+ }
251
+ }
252
+
253
+ // ═══════════════════════════════════════════════════════════════════════════
254
+ // EXPORTS
255
+ // ═══════════════════════════════════════════════════════════════════════════
256
+
257
+ module.exports = {
258
+ isJsYamlAvailable,
259
+ getJsYaml,
260
+ parseYaml,
261
+ serializeYaml,
262
+ parseYamlSimple,
263
+ serializeYamlSimple,
264
+ hasComplexYamlFeatures
265
+ };