@fgv/ts-extras 5.0.2 → 5.1.0-1

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.
Files changed (92) hide show
  1. package/dist/index.browser.js +6 -2
  2. package/dist/index.js +5 -1
  3. package/dist/packlets/ai-assist/apiClient.js +484 -0
  4. package/dist/packlets/ai-assist/converters.js +121 -0
  5. package/dist/packlets/ai-assist/index.js +10 -0
  6. package/dist/packlets/ai-assist/model.js +90 -0
  7. package/dist/packlets/ai-assist/registry.js +145 -0
  8. package/dist/packlets/ai-assist/toolFormats.js +160 -0
  9. package/dist/packlets/crypto-utils/constants.js +48 -0
  10. package/dist/packlets/crypto-utils/converters.js +155 -0
  11. package/dist/packlets/crypto-utils/directEncryptionProvider.js +86 -0
  12. package/dist/packlets/crypto-utils/encryptedFile.js +161 -0
  13. package/dist/packlets/crypto-utils/index.browser.js +41 -0
  14. package/dist/packlets/crypto-utils/index.js +41 -0
  15. package/dist/packlets/crypto-utils/keystore/converters.js +84 -0
  16. package/dist/packlets/crypto-utils/keystore/index.js +31 -0
  17. package/dist/packlets/crypto-utils/keystore/keyStore.js +758 -0
  18. package/dist/packlets/crypto-utils/keystore/model.js +64 -0
  19. package/dist/packlets/crypto-utils/model.js +39 -0
  20. package/dist/packlets/crypto-utils/nodeCryptoProvider.js +159 -0
  21. package/dist/packlets/experimental/formatter.js +1 -1
  22. package/dist/packlets/mustache/index.js +23 -0
  23. package/dist/packlets/mustache/interfaces.js +25 -0
  24. package/dist/packlets/mustache/mustacheTemplate.js +242 -0
  25. package/dist/packlets/record-jar/recordJarHelpers.js +1 -1
  26. package/dist/packlets/yaml/converters.js +46 -0
  27. package/dist/packlets/yaml/index.js +23 -0
  28. package/dist/packlets/zip-file-tree/index.js +1 -0
  29. package/dist/packlets/zip-file-tree/zipFileTreeAccessors.js +6 -2
  30. package/dist/packlets/zip-file-tree/zipFileTreeWriter.js +40 -0
  31. package/dist/ts-extras.d.ts +1964 -112
  32. package/dist/tsdoc-metadata.json +1 -1
  33. package/lib/index.browser.d.ts +3 -1
  34. package/lib/index.browser.js +6 -1
  35. package/lib/index.d.ts +5 -1
  36. package/lib/index.js +9 -1
  37. package/lib/packlets/ai-assist/apiClient.d.ts +60 -0
  38. package/lib/packlets/ai-assist/apiClient.js +488 -0
  39. package/lib/packlets/ai-assist/converters.d.ts +55 -0
  40. package/lib/packlets/ai-assist/converters.js +124 -0
  41. package/lib/packlets/ai-assist/index.d.ts +10 -0
  42. package/lib/packlets/ai-assist/index.js +33 -0
  43. package/lib/packlets/ai-assist/model.d.ts +222 -0
  44. package/lib/packlets/ai-assist/model.js +95 -0
  45. package/lib/packlets/ai-assist/registry.d.ts +25 -0
  46. package/lib/packlets/ai-assist/registry.js +150 -0
  47. package/lib/packlets/ai-assist/toolFormats.d.ts +44 -0
  48. package/lib/packlets/ai-assist/toolFormats.js +166 -0
  49. package/lib/packlets/crypto-utils/constants.d.ts +26 -0
  50. package/lib/packlets/crypto-utils/constants.js +51 -0
  51. package/lib/packlets/crypto-utils/converters.d.ts +58 -0
  52. package/lib/packlets/crypto-utils/converters.js +192 -0
  53. package/lib/packlets/crypto-utils/directEncryptionProvider.d.ts +69 -0
  54. package/lib/packlets/crypto-utils/directEncryptionProvider.js +90 -0
  55. package/lib/packlets/crypto-utils/encryptedFile.d.ts +88 -0
  56. package/lib/packlets/crypto-utils/encryptedFile.js +201 -0
  57. package/lib/packlets/crypto-utils/index.browser.d.ts +14 -0
  58. package/lib/packlets/crypto-utils/index.browser.js +91 -0
  59. package/lib/packlets/crypto-utils/index.d.ts +15 -0
  60. package/lib/packlets/crypto-utils/index.js +88 -0
  61. package/lib/packlets/crypto-utils/keystore/converters.d.ts +29 -0
  62. package/lib/packlets/crypto-utils/keystore/converters.js +87 -0
  63. package/lib/packlets/crypto-utils/keystore/index.d.ts +9 -0
  64. package/lib/packlets/crypto-utils/keystore/index.js +71 -0
  65. package/lib/packlets/crypto-utils/keystore/keyStore.d.ts +239 -0
  66. package/lib/packlets/crypto-utils/keystore/keyStore.js +795 -0
  67. package/lib/packlets/crypto-utils/keystore/model.d.ts +245 -0
  68. package/lib/packlets/crypto-utils/keystore/model.js +68 -0
  69. package/lib/packlets/crypto-utils/model.d.ts +236 -0
  70. package/lib/packlets/crypto-utils/model.js +76 -0
  71. package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +62 -0
  72. package/lib/packlets/crypto-utils/nodeCryptoProvider.js +196 -0
  73. package/lib/packlets/experimental/formatter.d.ts +1 -1
  74. package/lib/packlets/experimental/formatter.js +1 -1
  75. package/lib/packlets/mustache/index.d.ts +3 -0
  76. package/lib/packlets/mustache/index.js +27 -0
  77. package/lib/packlets/mustache/interfaces.d.ts +97 -0
  78. package/lib/packlets/mustache/interfaces.js +26 -0
  79. package/lib/packlets/mustache/mustacheTemplate.d.ts +76 -0
  80. package/lib/packlets/mustache/mustacheTemplate.js +249 -0
  81. package/lib/packlets/record-jar/recordJarHelpers.js +1 -1
  82. package/lib/packlets/yaml/converters.d.ts +9 -0
  83. package/lib/packlets/yaml/converters.js +82 -0
  84. package/lib/packlets/yaml/index.d.ts +2 -0
  85. package/lib/packlets/yaml/index.js +39 -0
  86. package/lib/packlets/zip-file-tree/index.d.ts +1 -0
  87. package/lib/packlets/zip-file-tree/index.js +15 -0
  88. package/lib/packlets/zip-file-tree/zipFileTreeAccessors.d.ts +5 -1
  89. package/lib/packlets/zip-file-tree/zipFileTreeAccessors.js +6 -2
  90. package/lib/packlets/zip-file-tree/zipFileTreeWriter.d.ts +27 -0
  91. package/lib/packlets/zip-file-tree/zipFileTreeWriter.js +43 -0
  92. package/package.json +50 -31
@@ -0,0 +1,758 @@
1
+ // Copyright (c) 2026 Erik Fortune
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+ import { captureResult, fail, succeed } from '@fgv/ts-utils';
21
+ import * as Constants from '../constants';
22
+ import { createEncryptedFile } from '../encryptedFile';
23
+ import { DEFAULT_KEYSTORE_ITERATIONS, DEFAULT_SECRET_ITERATIONS, KEYSTORE_FORMAT, MIN_SALT_LENGTH } from './model';
24
+ import { keystoreFile, keystoreVaultContents } from './converters';
25
+ /**
26
+ * Gets the current ISO timestamp.
27
+ */
28
+ function getCurrentTimestamp() {
29
+ return new Date().toISOString();
30
+ }
31
+ // ============================================================================
32
+ // KeyStore Class
33
+ // ============================================================================
34
+ /**
35
+ * Password-protected key store for managing encryption secrets.
36
+ *
37
+ * The KeyStore provides a secure vault for storing named encryption keys.
38
+ * The vault is encrypted at rest using a master password via PBKDF2 key derivation.
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * // Create new key store
43
+ * const keystore = KeyStore.create({ cryptoProvider: nodeCryptoProvider }).orThrow();
44
+ * await keystore.initialize('master-password');
45
+ *
46
+ * // Add secrets
47
+ * await keystore.addSecret('my-key', { description: 'Production key' });
48
+ *
49
+ * // Save to file
50
+ * const fileContent = await keystore.save();
51
+ *
52
+ * // Later: Open existing key store
53
+ * const keystore2 = KeyStore.open({
54
+ * cryptoProvider: nodeCryptoProvider,
55
+ * keystoreFile: fileContent.value
56
+ * }).orThrow();
57
+ * await keystore2.unlock('master-password');
58
+ *
59
+ * // Use as secret provider for encrypted file loading
60
+ * const encryptionConfig = keystore2.getEncryptionConfig().orThrow();
61
+ * ```
62
+ *
63
+ * @public
64
+ */
65
+ export class KeyStore {
66
+ constructor(cryptoProvider, iterations, keystoreFile, isNew = true) {
67
+ this._cryptoProvider = cryptoProvider;
68
+ this._iterations = iterations;
69
+ this._keystoreFile = keystoreFile;
70
+ this._state = 'locked';
71
+ this._dirty = false;
72
+ this._isNew = isNew;
73
+ }
74
+ // ============================================================================
75
+ // Factory Methods
76
+ // ============================================================================
77
+ /**
78
+ * Creates a new, empty key store.
79
+ * Call `initialize(password)` to set the master password.
80
+ * @param params - Creation parameters
81
+ * @returns Success with new KeyStore instance, or Failure if parameters invalid
82
+ * @public
83
+ */
84
+ static create(params) {
85
+ var _a;
86
+ const iterations = (_a = params.iterations) !== null && _a !== void 0 ? _a : DEFAULT_KEYSTORE_ITERATIONS;
87
+ if (iterations < 1) {
88
+ return fail('Iterations must be at least 1');
89
+ }
90
+ return succeed(new KeyStore(params.cryptoProvider, iterations, undefined, true));
91
+ }
92
+ /**
93
+ * Opens an existing encrypted key store.
94
+ * Call `unlock(password)` to decrypt and access secrets.
95
+ * @param params - Open parameters including the encrypted file
96
+ * @returns Success with KeyStore instance, or Failure if file format invalid
97
+ * @public
98
+ */
99
+ static open(params) {
100
+ // Validate the file format
101
+ const fileResult = keystoreFile.convert(params.keystoreFile);
102
+ if (fileResult.isFailure()) {
103
+ return fail(`Invalid key store file: ${fileResult.message}`);
104
+ }
105
+ const iterations = fileResult.value.keyDerivation.iterations;
106
+ return succeed(new KeyStore(params.cryptoProvider, iterations, fileResult.value, false));
107
+ }
108
+ // ============================================================================
109
+ // Lifecycle Methods
110
+ // ============================================================================
111
+ /**
112
+ * Initializes a new key store with the master password.
113
+ * Generates a random salt for key derivation.
114
+ * Only valid for newly created (not opened) key stores.
115
+ * @param password - The master password
116
+ * @returns Success with this instance when initialized, Failure if already initialized or opened
117
+ * @public
118
+ */
119
+ async initialize(password) {
120
+ if (!this._isNew) {
121
+ return fail('Cannot initialize an opened key store - use unlock() instead');
122
+ }
123
+ if (this._state === 'unlocked') {
124
+ return fail('Key store is already initialized');
125
+ }
126
+ if (!password || password.length === 0) {
127
+ return fail('Password cannot be empty');
128
+ }
129
+ // Generate salt for this key store using crypto provider
130
+ const saltResult = this._cryptoProvider.generateRandomBytes(MIN_SALT_LENGTH);
131
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
132
+ if (saltResult.isFailure()) {
133
+ return fail(`Failed to generate salt: ${saltResult.message}`);
134
+ }
135
+ this._salt = saltResult.value;
136
+ this._secrets = new Map();
137
+ this._state = 'unlocked';
138
+ this._dirty = true; // New store needs to be saved
139
+ return succeed(this);
140
+ }
141
+ /**
142
+ * Unlocks an existing key store with the master password.
143
+ * Decrypts the vault and loads secrets into memory.
144
+ * @param password - The master password
145
+ * @returns Success with this instance when unlocked, Failure if password incorrect
146
+ * @public
147
+ */
148
+ async unlock(password) {
149
+ var _a;
150
+ if (this._isNew) {
151
+ return fail('Cannot unlock a new key store - use initialize() instead');
152
+ }
153
+ /* c8 ignore next 6 - error paths tested but coverage intermittently missed */
154
+ if (this._state === 'unlocked') {
155
+ return fail('Key store is already unlocked');
156
+ }
157
+ if (!this._keystoreFile) {
158
+ return fail('No key store file to unlock');
159
+ }
160
+ const keyDerivation = this._keystoreFile.keyDerivation;
161
+ const saltResult = this._cryptoProvider.fromBase64(keyDerivation.salt);
162
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
163
+ if (saltResult.isFailure()) {
164
+ return fail(`Invalid salt in key store file: ${saltResult.message}`);
165
+ }
166
+ const salt = saltResult.value;
167
+ // Derive the key from password
168
+ const keyResult = await this._cryptoProvider.deriveKey(password, salt, keyDerivation.iterations);
169
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
170
+ if (keyResult.isFailure()) {
171
+ return fail(`Key derivation failed: ${keyResult.message}`);
172
+ }
173
+ // Decrypt the vault
174
+ const ivResult = this._cryptoProvider.fromBase64(this._keystoreFile.iv);
175
+ const authTagResult = this._cryptoProvider.fromBase64(this._keystoreFile.authTag);
176
+ const encryptedDataResult = this._cryptoProvider.fromBase64(this._keystoreFile.encryptedData);
177
+ /* c8 ignore next 9 - base64 decode errors tested but coverage intermittently missed */
178
+ if (ivResult.isFailure()) {
179
+ return fail(`Invalid IV in key store file: ${ivResult.message}`);
180
+ }
181
+ if (authTagResult.isFailure()) {
182
+ return fail(`Invalid auth tag in key store file: ${authTagResult.message}`);
183
+ }
184
+ if (encryptedDataResult.isFailure()) {
185
+ return fail(`Invalid encrypted data in key store file: ${encryptedDataResult.message}`);
186
+ }
187
+ const decryptResult = await this._cryptoProvider.decrypt(encryptedDataResult.value, keyResult.value, ivResult.value, authTagResult.value);
188
+ if (decryptResult.isFailure()) {
189
+ return fail('Incorrect password or corrupted key store');
190
+ }
191
+ // Parse the vault contents
192
+ const parseResult = captureResult(() => JSON.parse(decryptResult.value));
193
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
194
+ if (parseResult.isFailure()) {
195
+ return fail(`Failed to parse vault contents: ${parseResult.message}`);
196
+ }
197
+ const vaultResult = keystoreVaultContents.convert(parseResult.value);
198
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
199
+ if (vaultResult.isFailure()) {
200
+ return fail(`Invalid vault format: ${vaultResult.message}`);
201
+ }
202
+ // Load secrets into memory
203
+ this._salt = salt;
204
+ this._secrets = new Map();
205
+ for (const [name, jsonEntry] of Object.entries(vaultResult.value.secrets)) {
206
+ const keyBytesResult = this._cryptoProvider.fromBase64(jsonEntry.key);
207
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
208
+ if (keyBytesResult.isFailure()) {
209
+ return fail(`Invalid key for secret '${name}': ${keyBytesResult.message}`);
210
+ }
211
+ const entry = {
212
+ name,
213
+ /* c8 ignore next 1 - backwards compatibility: old vaults may lack type field */
214
+ type: (_a = jsonEntry.type) !== null && _a !== void 0 ? _a : 'encryption-key',
215
+ key: keyBytesResult.value,
216
+ description: jsonEntry.description,
217
+ createdAt: jsonEntry.createdAt
218
+ };
219
+ this._secrets.set(name, entry);
220
+ }
221
+ this._state = 'unlocked';
222
+ this._dirty = false;
223
+ return succeed(this);
224
+ }
225
+ /**
226
+ * Locks the key store, clearing all secrets from memory.
227
+ * @param force - If true, discards unsaved changes
228
+ * @returns Success when locked, Failure if unsaved changes and !force
229
+ * @public
230
+ */
231
+ lock(force) {
232
+ if (this._state === 'locked') {
233
+ return succeed(this);
234
+ }
235
+ if (this._dirty && !force) {
236
+ return fail('Unsaved changes - use force=true to discard or save() first');
237
+ }
238
+ // Clear secrets from memory (overwrite for security)
239
+ if (this._secrets) {
240
+ for (const entry of this._secrets.values()) {
241
+ entry.key.fill(0);
242
+ }
243
+ this._secrets.clear();
244
+ this._secrets = undefined;
245
+ }
246
+ this._state = 'locked';
247
+ this._dirty = false;
248
+ return succeed(this);
249
+ }
250
+ /**
251
+ * Checks if the key store is unlocked.
252
+ * @public
253
+ */
254
+ get isUnlocked() {
255
+ return this._state === 'unlocked';
256
+ }
257
+ /**
258
+ * Checks if there are unsaved changes.
259
+ * @public
260
+ */
261
+ get isDirty() {
262
+ return this._dirty;
263
+ }
264
+ /**
265
+ * Whether this is a newly created key store (not opened from a file).
266
+ * A new key store must be initialized with a password before use.
267
+ * An opened key store must be unlocked with the existing password.
268
+ * @public
269
+ */
270
+ get isNew() {
271
+ return this._isNew;
272
+ }
273
+ /**
274
+ * Gets the current lock state.
275
+ * @public
276
+ */
277
+ get state() {
278
+ return this._state;
279
+ }
280
+ /**
281
+ * Gets the crypto provider used by this key store.
282
+ * Available regardless of lock state.
283
+ * @public
284
+ */
285
+ get cryptoProvider() {
286
+ return this._cryptoProvider;
287
+ }
288
+ // ============================================================================
289
+ // Secret Management
290
+ // ============================================================================
291
+ /**
292
+ * Lists all secret names in the key store.
293
+ * @returns Success with array of secret names, Failure if locked
294
+ * @public
295
+ */
296
+ listSecrets() {
297
+ if (!this._secrets) {
298
+ return fail('Key store is locked');
299
+ }
300
+ return succeed(Array.from(this._secrets.keys()));
301
+ }
302
+ /**
303
+ * Gets a secret by name.
304
+ * @param name - Name of the secret
305
+ * @returns Success with secret entry, Failure if not found or locked
306
+ * @public
307
+ */
308
+ getSecret(name) {
309
+ if (!this._secrets) {
310
+ return fail('Key store is locked');
311
+ }
312
+ const entry = this._secrets.get(name);
313
+ if (!entry) {
314
+ return fail(`Secret '${name}' not found`);
315
+ }
316
+ return succeed(entry);
317
+ }
318
+ /**
319
+ * Checks if a secret exists.
320
+ * @param name - Name of the secret
321
+ * @returns Success with boolean, Failure if locked
322
+ * @public
323
+ */
324
+ hasSecret(name) {
325
+ if (!this._secrets) {
326
+ return fail('Key store is locked');
327
+ }
328
+ return succeed(this._secrets.has(name));
329
+ }
330
+ /**
331
+ * Adds a new secret with a randomly generated key.
332
+ * @param name - Unique name for the secret
333
+ * @param options - Optional description
334
+ * @returns Success with the generated entry, Failure if locked or name invalid
335
+ * @public
336
+ */
337
+ async addSecret(name, options) {
338
+ if (!this._secrets) {
339
+ return fail('Key store is locked');
340
+ }
341
+ if (!name || name.length === 0) {
342
+ return fail('Secret name cannot be empty');
343
+ }
344
+ const replaced = this._secrets.has(name);
345
+ // Generate a new random key
346
+ const keyResult = await this._cryptoProvider.generateKey();
347
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
348
+ if (keyResult.isFailure()) {
349
+ return fail(`Failed to generate key: ${keyResult.message}`);
350
+ }
351
+ const entry = {
352
+ name,
353
+ type: 'encryption-key',
354
+ key: keyResult.value,
355
+ description: options === null || options === void 0 ? void 0 : options.description,
356
+ createdAt: getCurrentTimestamp()
357
+ };
358
+ this._secrets.set(name, entry);
359
+ this._dirty = true;
360
+ return succeed({ entry, replaced });
361
+ }
362
+ /**
363
+ * Imports an existing secret key.
364
+ * @param name - Unique name for the secret
365
+ * @param key - The 32-byte AES-256 key
366
+ * @param options - Optional description, whether to replace existing
367
+ * @returns Success with entry, Failure if locked, key invalid, or exists and !replace
368
+ * @public
369
+ */
370
+ importSecret(name, key, options) {
371
+ if (!this._secrets) {
372
+ return fail('Key store is locked');
373
+ }
374
+ if (!name || name.length === 0) {
375
+ return fail('Secret name cannot be empty');
376
+ }
377
+ if (key.length !== Constants.AES_256_KEY_SIZE) {
378
+ return fail(`Key must be ${Constants.AES_256_KEY_SIZE} bytes, got ${key.length}`);
379
+ }
380
+ const exists = this._secrets.has(name);
381
+ if (exists && !(options === null || options === void 0 ? void 0 : options.replace)) {
382
+ return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
383
+ }
384
+ const entry = {
385
+ name,
386
+ type: 'encryption-key',
387
+ key: new Uint8Array(key), // Copy to prevent external modification
388
+ description: options === null || options === void 0 ? void 0 : options.description,
389
+ createdAt: getCurrentTimestamp()
390
+ };
391
+ this._secrets.set(name, entry);
392
+ this._dirty = true;
393
+ return succeed({ entry, replaced: exists });
394
+ }
395
+ /**
396
+ * Adds a secret derived from a password using PBKDF2.
397
+ *
398
+ * Generates a random salt, derives a 32-byte AES-256 key from the password,
399
+ * and stores it in the vault. Returns the key derivation parameters so they
400
+ * can be stored alongside encrypted files, enabling decryption with just the
401
+ * password (without unlocking the keystore).
402
+ *
403
+ * @param name - Unique name for the secret
404
+ * @param password - Password to derive the key from
405
+ * @param options - Optional description, iterations, replace flag
406
+ * @returns Success with entry and keyDerivation params, Failure if locked or invalid
407
+ * @public
408
+ */
409
+ async addSecretFromPassword(name, password, options) {
410
+ var _a;
411
+ if (!this._secrets) {
412
+ return fail('Key store is locked');
413
+ }
414
+ if (!name || name.length === 0) {
415
+ return fail('Secret name cannot be empty');
416
+ }
417
+ if (!password || password.length === 0) {
418
+ return fail('Password cannot be empty');
419
+ }
420
+ const exists = this._secrets.has(name);
421
+ if (exists && !(options === null || options === void 0 ? void 0 : options.replace)) {
422
+ return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
423
+ }
424
+ const iterations = (_a = options === null || options === void 0 ? void 0 : options.iterations) !== null && _a !== void 0 ? _a : DEFAULT_SECRET_ITERATIONS;
425
+ // Generate a random salt for this secret's key derivation
426
+ const saltResult = this._cryptoProvider.generateRandomBytes(MIN_SALT_LENGTH);
427
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
428
+ if (saltResult.isFailure()) {
429
+ return fail(`Failed to generate salt: ${saltResult.message}`);
430
+ }
431
+ // Derive the key from password
432
+ const keyResult = await this._cryptoProvider.deriveKey(password, saltResult.value, iterations);
433
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
434
+ if (keyResult.isFailure()) {
435
+ return fail(`Key derivation failed: ${keyResult.message}`);
436
+ }
437
+ const entry = {
438
+ name,
439
+ type: 'encryption-key',
440
+ key: keyResult.value,
441
+ description: options === null || options === void 0 ? void 0 : options.description,
442
+ createdAt: getCurrentTimestamp()
443
+ };
444
+ this._secrets.set(name, entry);
445
+ this._dirty = true;
446
+ return succeed({
447
+ entry,
448
+ replaced: exists,
449
+ keyDerivation: {
450
+ kdf: 'pbkdf2',
451
+ salt: this._cryptoProvider.toBase64(saltResult.value),
452
+ iterations
453
+ }
454
+ });
455
+ }
456
+ /**
457
+ * Removes a secret by name.
458
+ * @param name - Name of the secret to remove
459
+ * @returns Success with removed entry, Failure if not found or locked
460
+ * @public
461
+ */
462
+ removeSecret(name) {
463
+ if (!this._secrets) {
464
+ return fail('Key store is locked');
465
+ }
466
+ const entry = this._secrets.get(name);
467
+ if (!entry) {
468
+ return fail(`Secret '${name}' not found`);
469
+ }
470
+ // Clear the key before removing (security)
471
+ entry.key.fill(0);
472
+ this._secrets.delete(name);
473
+ this._dirty = true;
474
+ return succeed(entry);
475
+ }
476
+ /**
477
+ * Imports an API key string into the vault.
478
+ * The string is UTF-8 encoded and stored with type `'api-key'`.
479
+ * @param name - Unique name for the secret
480
+ * @param apiKey - The API key string
481
+ * @param options - Optional description, whether to replace existing
482
+ * @returns Success with entry, Failure if locked, empty, or exists and !replace
483
+ * @public
484
+ */
485
+ importApiKey(name, apiKey, options) {
486
+ if (!this._secrets) {
487
+ return fail('Key store is locked');
488
+ }
489
+ if (!name || name.length === 0) {
490
+ return fail('Secret name cannot be empty');
491
+ }
492
+ if (!apiKey || apiKey.length === 0) {
493
+ return fail('API key cannot be empty');
494
+ }
495
+ const exists = this._secrets.has(name);
496
+ if (exists && !(options === null || options === void 0 ? void 0 : options.replace)) {
497
+ return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
498
+ }
499
+ const encoder = new TextEncoder();
500
+ const entry = {
501
+ name,
502
+ type: 'api-key',
503
+ key: encoder.encode(apiKey),
504
+ description: options === null || options === void 0 ? void 0 : options.description,
505
+ createdAt: getCurrentTimestamp()
506
+ };
507
+ this._secrets.set(name, entry);
508
+ this._dirty = true;
509
+ return succeed({ entry, replaced: exists });
510
+ }
511
+ /**
512
+ * Retrieves an API key string by name.
513
+ * Only works for secrets with type `'api-key'`.
514
+ * @param name - Name of the secret
515
+ * @returns Success with the API key string, Failure if not found, locked, or wrong type
516
+ * @public
517
+ */
518
+ getApiKey(name) {
519
+ if (!this._secrets) {
520
+ return fail('Key store is locked');
521
+ }
522
+ const entry = this._secrets.get(name);
523
+ if (!entry) {
524
+ return fail(`Secret '${name}' not found`);
525
+ }
526
+ if (entry.type !== 'api-key') {
527
+ return fail(`Secret '${name}' is not an API key (type: ${entry.type})`);
528
+ }
529
+ const decoder = new TextDecoder();
530
+ return succeed(decoder.decode(entry.key));
531
+ }
532
+ /**
533
+ * Lists secret names filtered by type.
534
+ * @param type - The secret type to filter by
535
+ * @returns Success with array of matching secret names, Failure if locked
536
+ * @public
537
+ */
538
+ listSecretsByType(type) {
539
+ if (!this._secrets) {
540
+ return fail('Key store is locked');
541
+ }
542
+ const names = [];
543
+ for (const [name, entry] of this._secrets) {
544
+ if (entry.type === type) {
545
+ names.push(name);
546
+ }
547
+ }
548
+ return succeed(names);
549
+ }
550
+ /**
551
+ * Renames a secret.
552
+ * @param oldName - Current name
553
+ * @param newName - New name
554
+ * @returns Success with updated entry, Failure if source not found, target exists, or locked
555
+ * @public
556
+ */
557
+ renameSecret(oldName, newName) {
558
+ if (!this._secrets) {
559
+ return fail('Key store is locked');
560
+ }
561
+ if (!newName || newName.length === 0) {
562
+ return fail('New name cannot be empty');
563
+ }
564
+ const entry = this._secrets.get(oldName);
565
+ if (!entry) {
566
+ return fail(`Secret '${oldName}' not found`);
567
+ }
568
+ if (oldName !== newName && this._secrets.has(newName)) {
569
+ return fail(`Secret '${newName}' already exists`);
570
+ }
571
+ // Create new entry with new name (preserve type)
572
+ const newEntry = Object.assign(Object.assign({}, entry), { name: newName });
573
+ this._secrets.delete(oldName);
574
+ this._secrets.set(newName, newEntry);
575
+ this._dirty = true;
576
+ return succeed(newEntry);
577
+ }
578
+ // ============================================================================
579
+ // Persistence
580
+ // ============================================================================
581
+ /**
582
+ * Saves the key store, returning the encrypted file content.
583
+ * Requires the master password to encrypt.
584
+ * @param password - The master password
585
+ * @returns Success with IKeyStoreFile, Failure if locked
586
+ * @public
587
+ */
588
+ async save(password) {
589
+ if (!this._secrets || !this._salt) {
590
+ return fail('Key store is locked');
591
+ }
592
+ if (!password || password.length === 0) {
593
+ return fail('Password cannot be empty');
594
+ }
595
+ // Derive the encryption key
596
+ const keyResult = await this._cryptoProvider.deriveKey(password, this._salt, this._iterations);
597
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
598
+ if (keyResult.isFailure()) {
599
+ return fail(`Key derivation failed: ${keyResult.message}`);
600
+ }
601
+ // Build vault contents
602
+ const secrets = {};
603
+ for (const [name, entry] of this._secrets) {
604
+ secrets[name] = {
605
+ name: entry.name,
606
+ type: entry.type,
607
+ key: this._cryptoProvider.toBase64(entry.key),
608
+ description: entry.description,
609
+ createdAt: entry.createdAt
610
+ };
611
+ }
612
+ const vaultContents = {
613
+ version: KEYSTORE_FORMAT,
614
+ secrets
615
+ };
616
+ // Serialize and encrypt
617
+ const jsonResult = captureResult(() => JSON.stringify(vaultContents));
618
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
619
+ if (jsonResult.isFailure()) {
620
+ return fail(`Failed to serialize vault: ${jsonResult.message}`);
621
+ }
622
+ const encryptResult = await this._cryptoProvider.encrypt(jsonResult.value, keyResult.value);
623
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
624
+ if (encryptResult.isFailure()) {
625
+ return fail(`Encryption failed: ${encryptResult.message}`);
626
+ }
627
+ const { iv, authTag, encryptedData } = encryptResult.value;
628
+ const keystoreFileData = {
629
+ format: KEYSTORE_FORMAT,
630
+ algorithm: Constants.DEFAULT_ALGORITHM,
631
+ iv: this._cryptoProvider.toBase64(iv),
632
+ authTag: this._cryptoProvider.toBase64(authTag),
633
+ encryptedData: this._cryptoProvider.toBase64(encryptedData),
634
+ keyDerivation: {
635
+ kdf: 'pbkdf2',
636
+ salt: this._cryptoProvider.toBase64(this._salt),
637
+ iterations: this._iterations
638
+ }
639
+ };
640
+ this._keystoreFile = keystoreFileData;
641
+ this._dirty = false;
642
+ this._isNew = false;
643
+ return succeed(keystoreFileData);
644
+ }
645
+ /**
646
+ * Changes the master password.
647
+ * Re-encrypts the vault with the new password-derived key.
648
+ * @param currentPassword - Current master password (for verification)
649
+ * @param newPassword - New master password
650
+ * @returns Success when password changed, Failure if locked or current password incorrect
651
+ * @public
652
+ */
653
+ async changePassword(currentPassword, newPassword) {
654
+ if (!this._secrets || !this._salt) {
655
+ return fail('Key store is locked');
656
+ }
657
+ if (!newPassword || newPassword.length === 0) {
658
+ return fail('New password cannot be empty');
659
+ }
660
+ // Verify current password by trying to derive and re-encrypt
661
+ // (For opened stores, we'd need to verify against the stored file)
662
+ if (this._keystoreFile) {
663
+ const saltResult = this._cryptoProvider.fromBase64(this._keystoreFile.keyDerivation.salt);
664
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
665
+ if (saltResult.isFailure()) {
666
+ return fail(`Invalid salt in key store file: ${saltResult.message}`);
667
+ }
668
+ const keyResult = await this._cryptoProvider.deriveKey(currentPassword, saltResult.value, this._keystoreFile.keyDerivation.iterations);
669
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
670
+ if (keyResult.isFailure()) {
671
+ return fail(`Key derivation failed: ${keyResult.message}`);
672
+ }
673
+ // Try to decrypt to verify password
674
+ const ivResult = this._cryptoProvider.fromBase64(this._keystoreFile.iv);
675
+ const authTagResult = this._cryptoProvider.fromBase64(this._keystoreFile.authTag);
676
+ const encryptedDataResult = this._cryptoProvider.fromBase64(this._keystoreFile.encryptedData);
677
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
678
+ if (ivResult.isFailure() || authTagResult.isFailure() || encryptedDataResult.isFailure()) {
679
+ return fail('Invalid key store file format');
680
+ }
681
+ const decryptResult = await this._cryptoProvider.decrypt(encryptedDataResult.value, keyResult.value, ivResult.value, authTagResult.value);
682
+ if (decryptResult.isFailure()) {
683
+ return fail('Current password is incorrect');
684
+ }
685
+ }
686
+ // Generate new salt for the new password using crypto provider
687
+ const saltResult = this._cryptoProvider.generateRandomBytes(MIN_SALT_LENGTH);
688
+ /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
689
+ if (saltResult.isFailure()) {
690
+ return fail(`Failed to generate salt: ${saltResult.message}`);
691
+ }
692
+ this._salt = saltResult.value;
693
+ this._dirty = true;
694
+ // Save with new password
695
+ const saveResult = await this.save(newPassword);
696
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
697
+ if (saveResult.isFailure()) {
698
+ return fail(saveResult.message);
699
+ }
700
+ return succeed(this);
701
+ }
702
+ // ============================================================================
703
+ // IEncryptionProvider
704
+ // ============================================================================
705
+ /** {@inheritDoc IEncryptionProvider.encryptByName} */
706
+ async encryptByName(secretName, content, metadata) {
707
+ const secretResult = this.getSecret(secretName);
708
+ if (secretResult.isFailure()) {
709
+ return fail(`encryptByName: ${secretResult.message}`);
710
+ }
711
+ return createEncryptedFile({
712
+ content,
713
+ secretName,
714
+ key: secretResult.value.key,
715
+ cryptoProvider: this._cryptoProvider,
716
+ metadata
717
+ });
718
+ }
719
+ // ============================================================================
720
+ // Integration with IEncryptionConfig
721
+ // ============================================================================
722
+ /**
723
+ * Creates a SecretProvider function for use with IEncryptionConfig.
724
+ * The returned function looks up secrets from this key store.
725
+ * @returns Success with SecretProvider, Failure if locked
726
+ * @public
727
+ */
728
+ getSecretProvider() {
729
+ if (!this._secrets) {
730
+ return fail('Key store is locked');
731
+ }
732
+ const secrets = this._secrets;
733
+ const provider = async (secretName) => {
734
+ const entry = secrets.get(secretName);
735
+ if (!entry) {
736
+ return fail(`Secret '${secretName}' not found in key store`);
737
+ }
738
+ return succeed(entry.key);
739
+ };
740
+ return succeed(provider);
741
+ }
742
+ /**
743
+ * Creates a partial IEncryptionConfig using this key store as the secret source.
744
+ * @returns Partial config that can be spread into a full IEncryptionConfig
745
+ * @public
746
+ */
747
+ getEncryptionConfig() {
748
+ const providerResult = this.getSecretProvider();
749
+ if (providerResult.isFailure()) {
750
+ return fail(providerResult.message);
751
+ }
752
+ return succeed({
753
+ secretProvider: providerResult.value,
754
+ cryptoProvider: this._cryptoProvider
755
+ });
756
+ }
757
+ }
758
+ //# sourceMappingURL=keyStore.js.map