@fgv/ts-extras 5.1.0-17 → 5.1.0-19

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 (43) hide show
  1. package/dist/packlets/ai-assist/apiClient.js +247 -24
  2. package/dist/packlets/ai-assist/index.js +1 -1
  3. package/dist/packlets/ai-assist/registry.js +49 -4
  4. package/dist/packlets/crypto-utils/index.browser.js +2 -0
  5. package/dist/packlets/crypto-utils/index.js +2 -0
  6. package/dist/packlets/crypto-utils/keyPairAlgorithmParams.js +47 -0
  7. package/dist/packlets/crypto-utils/keystore/converters.js +101 -9
  8. package/dist/packlets/crypto-utils/keystore/index.js +1 -0
  9. package/dist/packlets/crypto-utils/keystore/keyStore.js +271 -46
  10. package/dist/packlets/crypto-utils/keystore/model.js +22 -1
  11. package/dist/packlets/crypto-utils/keystore/privateKeyStorage.js +21 -0
  12. package/dist/packlets/crypto-utils/model.js +5 -0
  13. package/dist/packlets/crypto-utils/nodeCryptoProvider.js +140 -1
  14. package/dist/test/unit/crypto/keystore/inMemoryPrivateKeyStorage.js +78 -0
  15. package/dist/ts-extras.d.ts +799 -40
  16. package/lib/packlets/ai-assist/apiClient.d.ts +11 -3
  17. package/lib/packlets/ai-assist/apiClient.js +245 -22
  18. package/lib/packlets/ai-assist/index.d.ts +2 -2
  19. package/lib/packlets/ai-assist/index.js +3 -1
  20. package/lib/packlets/ai-assist/model.d.ts +66 -5
  21. package/lib/packlets/ai-assist/registry.d.ts +25 -1
  22. package/lib/packlets/ai-assist/registry.js +51 -4
  23. package/lib/packlets/crypto-utils/index.browser.d.ts +1 -0
  24. package/lib/packlets/crypto-utils/index.browser.js +4 -1
  25. package/lib/packlets/crypto-utils/index.d.ts +1 -0
  26. package/lib/packlets/crypto-utils/index.js +4 -1
  27. package/lib/packlets/crypto-utils/keyPairAlgorithmParams.d.ts +39 -0
  28. package/lib/packlets/crypto-utils/keyPairAlgorithmParams.js +50 -0
  29. package/lib/packlets/crypto-utils/keystore/converters.d.ts +68 -6
  30. package/lib/packlets/crypto-utils/keystore/converters.js +100 -8
  31. package/lib/packlets/crypto-utils/keystore/index.d.ts +1 -0
  32. package/lib/packlets/crypto-utils/keystore/index.js +1 -0
  33. package/lib/packlets/crypto-utils/keystore/keyStore.d.ts +77 -9
  34. package/lib/packlets/crypto-utils/keystore/keyStore.js +271 -46
  35. package/lib/packlets/crypto-utils/keystore/model.d.ts +238 -19
  36. package/lib/packlets/crypto-utils/keystore/model.js +24 -2
  37. package/lib/packlets/crypto-utils/keystore/privateKeyStorage.d.ts +50 -0
  38. package/lib/packlets/crypto-utils/keystore/privateKeyStorage.js +22 -0
  39. package/lib/packlets/crypto-utils/model.d.ts +130 -0
  40. package/lib/packlets/crypto-utils/model.js +6 -1
  41. package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +45 -1
  42. package/lib/packlets/crypto-utils/nodeCryptoProvider.js +139 -0
  43. package/package.json +7 -7
@@ -17,9 +17,9 @@
17
17
  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
18
  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
19
  // SOFTWARE.
20
- import { Converters } from '@fgv/ts-utils';
20
+ import { Converters, succeed, Validators } from '@fgv/ts-utils';
21
21
  import { base64String, encryptionAlgorithm, keyDerivationParams } from '../converters';
22
- import { allKeyStoreSecretTypes, KEYSTORE_FORMAT } from './model';
22
+ import { allKeyPairAlgorithms, allKeyStoreSecretTypes, allKeyStoreSymmetricSecretTypes, KEYSTORE_FORMAT, allKeyStoreAsymmetricSecretTypes } from './model';
23
23
  // ============================================================================
24
24
  // Key Store Format Converter
25
25
  // ============================================================================
@@ -31,31 +31,123 @@ export const keystoreFormat = Converters.enumeratedValue([
31
31
  KEYSTORE_FORMAT
32
32
  ]);
33
33
  // ============================================================================
34
- // Secret Type Converter
34
+ // Secret Type Converters
35
35
  // ============================================================================
36
36
  /**
37
- * Converter for {@link CryptoUtils.KeyStore.KeyStoreSecretType | key store secret type} discriminator.
37
+ * Converter for {@link CryptoUtils.KeyStore.KeyStoreSecretType | any key store secret type} discriminator.
38
+ * Accepts both symmetric and asymmetric type values.
38
39
  * @public
39
40
  */
40
41
  export const keystoreSecretType = Converters.enumeratedValue(allKeyStoreSecretTypes);
42
+ /**
43
+ * Converter for {@link CryptoUtils.KeyStore.KeyStoreSymmetricSecretType | symmetric secret type} discriminator.
44
+ * Accepts only `'encryption-key'` and `'api-key'`.
45
+ * @public
46
+ */
47
+ export const keystoreSymmetricSecretType = Converters.enumeratedValue(allKeyStoreSymmetricSecretTypes);
48
+ /**
49
+ * Converter for {@link CryptoUtils.KeyStore.KeyStoreAsymmetricSecretType | asymmetric secret type} discriminator.
50
+ * Accepts only `'asymmetric-keypair'`.
51
+ * @public
52
+ */
53
+ export const keystoreAsymmetricSecretType = Converters.enumeratedValue(allKeyStoreAsymmetricSecretTypes);
54
+ // ============================================================================
55
+ // Key Pair Algorithm Converter
56
+ // ============================================================================
57
+ /**
58
+ * Converter for {@link CryptoUtils.KeyStore.KeyPairAlgorithm | key pair algorithm}.
59
+ * @public
60
+ */
61
+ export const keyPairAlgorithm = Converters.enumeratedValue(allKeyPairAlgorithms);
41
62
  // ============================================================================
42
- // Secret Entry Converters
63
+ // JWK Shape Validator
43
64
  // ============================================================================
44
65
  /**
45
- * Converter for {@link CryptoUtils.KeyStore.IKeyStoreSecretEntryJson | key store secret entry} in JSON format.
46
- * The `type` field is optional for backwards compatibility — missing means `'encryption-key'`.
66
+ * In-place shape check for a JSON Web Key. Asserts only that the input is a
67
+ * non-array object whose `kty` discriminator is a string; every other JWK
68
+ * field passes through untouched. This is intentionally **not** a true JWK
69
+ * validator — per-algorithm correctness (RSA `n`/`e`, EC `crv`/`x`/`y`,
70
+ * key-size constraints, etc.) is delegated to `crypto.subtle.importKey` at
71
+ * first use, which is the authoritative checker. The "shape" suffix in the
72
+ * name is the warning sign for readers expecting full validation.
73
+ * @remarks
74
+ * Built with `Validators.object` (in-place, non-strict) so unknown JWK fields
75
+ * survive the round-trip; the cast to `FieldValidators<JsonWebKey>` is required
76
+ * only because TypeScript's mapped type demands an entry for every key in
77
+ * `JsonWebKey`. At runtime the `ObjectValidator` only inspects keys present in
78
+ * the field-validators map.
47
79
  * @public
48
80
  */
49
- export const keystoreSecretEntryJson = Converters.object({
81
+ export const jsonWebKeyShape = Validators.object({
82
+ kty: Validators.string
83
+ });
84
+ // ============================================================================
85
+ // Symmetric Secret Entry Converter
86
+ // ============================================================================
87
+ /**
88
+ * Converter for {@link CryptoUtils.KeyStore.IKeyStoreSymmetricEntryJson | symmetric secret entry} in JSON form.
89
+ *
90
+ * @remarks
91
+ * Backwards compatibility with vaults written before asymmetric-keypair
92
+ * support: those entries may lack the `type` discriminator on the wire. To
93
+ * keep the model type honest (`type` is required on
94
+ * {@link CryptoUtils.KeyStore.IKeyStoreSymmetricEntryJson}, see its docs),
95
+ * we declare `type` in `optionalFields` so the inner `Converters.object` will
96
+ * accept input without it, then `.map()` injects the default
97
+ * `'encryption-key'` when missing. The output therefore always carries the
98
+ * discriminator and downstream code never sees the legacy missing-type form.
99
+ *
100
+ * @public
101
+ */
102
+ export const keystoreSymmetricEntryJson = Converters.object({
50
103
  name: Converters.string,
51
- type: keystoreSecretType,
104
+ type: keystoreSymmetricSecretType,
52
105
  key: base64String,
53
106
  description: Converters.string,
54
107
  createdAt: Converters.string
55
108
  }, {
109
+ // `type` is optional at the input layer for legacy-vault compatibility;
110
+ // the .map() below normalizes by injecting the default.
56
111
  optionalFields: ['type', 'description']
112
+ }).map((entry) => {
113
+ var _a;
114
+ return succeed(Object.assign(Object.assign({}, entry), { type: (_a = entry.type) !== null && _a !== void 0 ? _a : 'encryption-key' }));
57
115
  });
58
116
  // ============================================================================
117
+ // Asymmetric Keypair Entry Converter
118
+ // ============================================================================
119
+ /**
120
+ * Converter for {@link CryptoUtils.KeyStore.IKeyStoreAsymmetricEntryJson | asymmetric keypair entry} in JSON form.
121
+ * The `publicKeyJwk` field passes through {@link CryptoUtils.KeyStore.Converters.jsonWebKeyShape | jsonWebKeyShape}
122
+ * (shape check only — see its docs); cryptographic correctness is enforced by
123
+ * `crypto.subtle.importKey` at use.
124
+ * @public
125
+ */
126
+ export const keystoreAsymmetricEntryJson = Converters.object({
127
+ name: Converters.string,
128
+ type: keystoreAsymmetricSecretType,
129
+ id: Converters.string,
130
+ algorithm: keyPairAlgorithm,
131
+ publicKeyJwk: jsonWebKeyShape,
132
+ description: Converters.string.optional(),
133
+ createdAt: Converters.string
134
+ });
135
+ // ============================================================================
136
+ // Discriminated-Union Entry Converter
137
+ // ============================================================================
138
+ /**
139
+ * Discriminated-union converter for any {@link CryptoUtils.KeyStore.IKeyStoreEntryJson | key store entry} in JSON form.
140
+ * Routes by the `type` field: `'asymmetric-keypair'` is parsed by
141
+ * {@link CryptoUtils.KeyStore.Converters.keystoreAsymmetricEntryJson | keystoreAsymmetricEntryJson},
142
+ * anything else (including a missing `type` field for backwards compatibility) by
143
+ * {@link CryptoUtils.KeyStore.Converters.keystoreSymmetricEntryJson | keystoreSymmetricEntryJson}.
144
+ * @public
145
+ */
146
+ export const keystoreSecretEntryJson = Converters.oneOf([
147
+ keystoreAsymmetricEntryJson,
148
+ keystoreSymmetricEntryJson
149
+ ]);
150
+ // ============================================================================
59
151
  // Vault Contents Converter
60
152
  // ============================================================================
61
153
  /**
@@ -23,6 +23,7 @@
23
23
  */
24
24
  // Types and interfaces
25
25
  export * from './model';
26
+ export * from './privateKeyStorage';
26
27
  // Converters namespace
27
28
  import * as Converters from './converters';
28
29
  export { Converters };
@@ -63,8 +63,9 @@ function getCurrentTimestamp() {
63
63
  * @public
64
64
  */
65
65
  export class KeyStore {
66
- constructor(cryptoProvider, iterations, keystoreFile, isNew = true) {
66
+ constructor(cryptoProvider, iterations, keystoreFile, isNew, privateKeyStorage) {
67
67
  this._cryptoProvider = cryptoProvider;
68
+ this._privateKeyStorage = privateKeyStorage;
68
69
  this._iterations = iterations;
69
70
  this._keystoreFile = keystoreFile;
70
71
  this._state = 'locked';
@@ -87,7 +88,7 @@ export class KeyStore {
87
88
  if (iterations < 1) {
88
89
  return fail('Iterations must be at least 1');
89
90
  }
90
- return succeed(new KeyStore(params.cryptoProvider, iterations, undefined, true));
91
+ return succeed(new KeyStore(params.cryptoProvider, iterations, undefined, true, params.privateKeyStorage));
91
92
  }
92
93
  /**
93
94
  * Opens an existing encrypted key store.
@@ -103,7 +104,7 @@ export class KeyStore {
103
104
  return fail(`Invalid key store file: ${fileResult.message}`);
104
105
  }
105
106
  const iterations = fileResult.value.keyDerivation.iterations;
106
- return succeed(new KeyStore(params.cryptoProvider, iterations, fileResult.value, false));
107
+ return succeed(new KeyStore(params.cryptoProvider, iterations, fileResult.value, false, params.privateKeyStorage));
107
108
  }
108
109
  // ============================================================================
109
110
  // Lifecycle Methods
@@ -217,7 +218,9 @@ export class KeyStore {
217
218
  // Clear secrets from memory (overwrite for security)
218
219
  if (this._secrets) {
219
220
  for (const entry of this._secrets.values()) {
220
- entry.key.fill(0);
221
+ if (entry.type !== 'asymmetric-keypair') {
222
+ entry.key.fill(0);
223
+ }
221
224
  }
222
225
  this._secrets.clear();
223
226
  this._secrets = undefined;
@@ -279,7 +282,9 @@ export class KeyStore {
279
282
  return succeed(Array.from(this._secrets.keys()));
280
283
  }
281
284
  /**
282
- * Gets a secret by name.
285
+ * Gets a secret by name. Returns the {@link CryptoUtils.KeyStore.IKeyStoreEntry | discriminated union}
286
+ * — callers must check `entry.type` before accessing `key`/`id` since asymmetric
287
+ * entries carry no raw key material.
283
288
  * @param name - Name of the secret
284
289
  * @returns Success with secret entry, Failure if not found or locked
285
290
  * @public
@@ -294,6 +299,27 @@ export class KeyStore {
294
299
  }
295
300
  return succeed(entry);
296
301
  }
302
+ /**
303
+ * Returns the public-key JWK for an asymmetric-keypair entry.
304
+ * Available without {@link CryptoUtils.KeyStore.IPrivateKeyStorage} since the
305
+ * public key lives in the vault metadata directly.
306
+ * @param name - Name of the entry
307
+ * @returns Success with the JWK, Failure if not found, locked, or wrong type
308
+ * @public
309
+ */
310
+ getPublicKeyJwk(name) {
311
+ if (!this._secrets) {
312
+ return fail('Key store is locked');
313
+ }
314
+ const entry = this._secrets.get(name);
315
+ if (!entry) {
316
+ return fail(`Secret '${name}' not found`);
317
+ }
318
+ if (entry.type !== 'asymmetric-keypair') {
319
+ return fail(`Secret '${name}' is not an asymmetric keypair (type: ${entry.type})`);
320
+ }
321
+ return succeed(entry.publicKeyJwk);
322
+ }
297
323
  /**
298
324
  * Checks if a secret exists.
299
325
  * @param name - Name of the secret
@@ -320,7 +346,6 @@ export class KeyStore {
320
346
  if (!name || name.length === 0) {
321
347
  return fail('Secret name cannot be empty');
322
348
  }
323
- const replaced = this._secrets.has(name);
324
349
  // Generate a new random key
325
350
  const keyResult = await this._cryptoProvider.generateKey();
326
351
  /* c8 ignore next 3 - crypto provider errors tested but coverage intermittently missed */
@@ -334,9 +359,11 @@ export class KeyStore {
334
359
  description: options === null || options === void 0 ? void 0 : options.description,
335
360
  createdAt: getCurrentTimestamp()
336
361
  };
362
+ const existing = this._secrets.get(name);
363
+ const warning = existing ? await this._releaseEntryResources(existing) : undefined;
337
364
  this._secrets.set(name, entry);
338
365
  this._dirty = true;
339
- return succeed({ entry, replaced });
366
+ return succeed({ entry, replaced: existing !== undefined, warning });
340
367
  }
341
368
  /**
342
369
  * Imports raw 32-byte key material into the vault.
@@ -352,7 +379,7 @@ export class KeyStore {
352
379
  * @returns Success with entry, Failure if locked, key invalid, or exists and !replace
353
380
  * @public
354
381
  */
355
- importSecret(name, key, options) {
382
+ async importSecret(name, key, options) {
356
383
  var _a;
357
384
  if (!this._secrets) {
358
385
  return fail('Key store is locked');
@@ -363,8 +390,8 @@ export class KeyStore {
363
390
  if (key.length !== Constants.AES_256_KEY_SIZE) {
364
391
  return fail(`Key must be ${Constants.AES_256_KEY_SIZE} bytes, got ${key.length}`);
365
392
  }
366
- const exists = this._secrets.has(name);
367
- if (exists && !(options === null || options === void 0 ? void 0 : options.replace)) {
393
+ const existing = this._secrets.get(name);
394
+ if (existing && !(options === null || options === void 0 ? void 0 : options.replace)) {
368
395
  return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
369
396
  }
370
397
  const entry = {
@@ -374,9 +401,10 @@ export class KeyStore {
374
401
  description: options === null || options === void 0 ? void 0 : options.description,
375
402
  createdAt: getCurrentTimestamp()
376
403
  };
404
+ const warning = existing ? await this._releaseEntryResources(existing) : undefined;
377
405
  this._secrets.set(name, entry);
378
406
  this._dirty = true;
379
- return succeed({ entry, replaced: exists });
407
+ return succeed({ entry, replaced: existing !== undefined, warning });
380
408
  }
381
409
  /**
382
410
  * Adds a secret derived from a password using PBKDF2.
@@ -403,8 +431,8 @@ export class KeyStore {
403
431
  if (!password || password.length === 0) {
404
432
  return fail('Password cannot be empty');
405
433
  }
406
- const exists = this._secrets.has(name);
407
- if (exists && !(options === null || options === void 0 ? void 0 : options.replace)) {
434
+ const existing = this._secrets.get(name);
435
+ if (existing && !(options === null || options === void 0 ? void 0 : options.replace)) {
408
436
  return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
409
437
  }
410
438
  const iterations = (_a = options === null || options === void 0 ? void 0 : options.iterations) !== null && _a !== void 0 ? _a : DEFAULT_SECRET_ITERATIONS;
@@ -427,11 +455,13 @@ export class KeyStore {
427
455
  description: options === null || options === void 0 ? void 0 : options.description,
428
456
  createdAt: getCurrentTimestamp()
429
457
  };
458
+ const warning = existing ? await this._releaseEntryResources(existing) : undefined;
430
459
  this._secrets.set(name, entry);
431
460
  this._dirty = true;
432
461
  return succeed({
433
462
  entry,
434
- replaced: exists,
463
+ replaced: existing !== undefined,
464
+ warning,
435
465
  keyDerivation: {
436
466
  kdf: 'pbkdf2',
437
467
  salt: this._cryptoProvider.toBase64(saltResult.value),
@@ -440,12 +470,16 @@ export class KeyStore {
440
470
  });
441
471
  }
442
472
  /**
443
- * Removes a secret by name.
473
+ * Removes a secret by name. Vault-first: the in-memory vault entry is dropped
474
+ * before any storage cleanup runs. For asymmetric-keypair entries, best-effort
475
+ * calls {@link CryptoUtils.KeyStore.IPrivateKeyStorage}.delete on the entry's
476
+ * `id`; a failure is reported via `warning` on the result but does not roll
477
+ * back the vault removal.
444
478
  * @param name - Name of the secret to remove
445
- * @returns Success with removed entry, Failure if not found or locked
479
+ * @returns Success with removed entry (and optional warning), Failure if not found or locked
446
480
  * @public
447
481
  */
448
- removeSecret(name) {
482
+ async removeSecret(name) {
449
483
  if (!this._secrets) {
450
484
  return fail('Key store is locked');
451
485
  }
@@ -453,11 +487,12 @@ export class KeyStore {
453
487
  if (!entry) {
454
488
  return fail(`Secret '${name}' not found`);
455
489
  }
456
- // Clear the key before removing (security)
457
- entry.key.fill(0);
490
+ // Vault-first: drop the in-memory entry before touching storage so a
491
+ // storage failure cannot block removal.
458
492
  this._secrets.delete(name);
459
493
  this._dirty = true;
460
- return succeed(entry);
494
+ const warning = await this._releaseEntryResources(entry);
495
+ return succeed({ entry, warning });
461
496
  }
462
497
  /**
463
498
  * Imports an API key string into the vault.
@@ -468,7 +503,7 @@ export class KeyStore {
468
503
  * @returns Success with entry, Failure if locked, empty, or exists and !replace
469
504
  * @public
470
505
  */
471
- importApiKey(name, apiKey, options) {
506
+ async importApiKey(name, apiKey, options) {
472
507
  if (!this._secrets) {
473
508
  return fail('Key store is locked');
474
509
  }
@@ -478,8 +513,8 @@ export class KeyStore {
478
513
  if (!apiKey || apiKey.length === 0) {
479
514
  return fail('API key cannot be empty');
480
515
  }
481
- const exists = this._secrets.has(name);
482
- if (exists && !(options === null || options === void 0 ? void 0 : options.replace)) {
516
+ const existing = this._secrets.get(name);
517
+ if (existing && !(options === null || options === void 0 ? void 0 : options.replace)) {
483
518
  return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
484
519
  }
485
520
  const encoder = new TextEncoder();
@@ -490,9 +525,10 @@ export class KeyStore {
490
525
  description: options === null || options === void 0 ? void 0 : options.description,
491
526
  createdAt: getCurrentTimestamp()
492
527
  };
528
+ const warning = existing ? await this._releaseEntryResources(existing) : undefined;
493
529
  this._secrets.set(name, entry);
494
530
  this._dirty = true;
495
- return succeed({ entry, replaced: exists });
531
+ return succeed({ entry, replaced: existing !== undefined, warning });
496
532
  }
497
533
  /**
498
534
  * Retrieves an API key string by name.
@@ -515,6 +551,118 @@ export class KeyStore {
515
551
  const decoder = new TextDecoder();
516
552
  return succeed(decoder.decode(entry.key));
517
553
  }
554
+ // ============================================================================
555
+ // Asymmetric Keypair Management
556
+ // ============================================================================
557
+ /**
558
+ * Adds a new asymmetric keypair to the vault. Storage-first: the private key
559
+ * is stored under a freshly-minted `id` before the public-key vault entry is
560
+ * committed. If the storage call fails, no vault entry is written and the
561
+ * operation returns Failure.
562
+ *
563
+ * When `replace: true` displaces an existing entry (asymmetric or symmetric),
564
+ * a fresh `id` is minted; the displaced entry's resources are released
565
+ * best-effort. Failure of the storage delete is reported via `warning` on the
566
+ * result but does not roll back the replacement.
567
+ *
568
+ * Requires a {@link CryptoUtils.KeyStore.IPrivateKeyStorage} backend
569
+ * supplied at construction.
570
+ *
571
+ * @param name - Unique name for the entry
572
+ * @param options - Algorithm, optional description, replace flag
573
+ * @returns Success with the new entry, Failure if locked, no provider, or storage write failed
574
+ * @public
575
+ */
576
+ async addKeyPair(name, options) {
577
+ if (!this._secrets) {
578
+ return fail('Key store is locked');
579
+ }
580
+ if (!name || name.length === 0) {
581
+ return fail('Entry name cannot be empty');
582
+ }
583
+ if (!this._privateKeyStorage) {
584
+ return fail('No private key storage configured');
585
+ }
586
+ const existing = this._secrets.get(name);
587
+ if (existing && !options.replace) {
588
+ return fail(`Secret '${name}' already exists - use replace=true to overwrite`);
589
+ }
590
+ // Generate the keypair before touching storage. extractable=true on backends
591
+ // that round-trip via JWK; extractable=false on backends that hold CryptoKey
592
+ // refs directly.
593
+ const extractable = !this._privateKeyStorage.supportsNonExtractable;
594
+ const keyPairResult = await this._cryptoProvider.generateKeyPair(options.algorithm, extractable);
595
+ /* c8 ignore next 3 - crypto provider errors covered in nodeCryptoProvider tests; cannot be triggered here without mocking */
596
+ if (keyPairResult.isFailure()) {
597
+ return fail(`Failed to generate keypair for '${name}': ${keyPairResult.message}`);
598
+ }
599
+ const { publicKey, privateKey } = keyPairResult.value;
600
+ const jwkResult = await this._cryptoProvider.exportPublicKeyJwk(publicKey);
601
+ /* c8 ignore next 3 - export of an extractable freshly-generated public key is hard to fail */
602
+ if (jwkResult.isFailure()) {
603
+ return fail(`Failed to export public key for '${name}': ${jwkResult.message}`);
604
+ }
605
+ const idResult = this._generateId();
606
+ /* c8 ignore next 3 - random-bytes failure is hard to trigger with a healthy provider */
607
+ if (idResult.isFailure()) {
608
+ return fail(`Failed to mint storage id for '${name}': ${idResult.message}`);
609
+ }
610
+ const id = idResult.value;
611
+ // Storage-first: write the private key before committing the vault entry.
612
+ const storeResult = await this._privateKeyStorage.store(id, privateKey);
613
+ if (storeResult.isFailure()) {
614
+ return fail(`Failed to persist private key for '${name}': ${storeResult.message}`);
615
+ }
616
+ const entry = {
617
+ name,
618
+ type: 'asymmetric-keypair',
619
+ id,
620
+ algorithm: options.algorithm,
621
+ publicKeyJwk: jwkResult.value,
622
+ description: options.description,
623
+ createdAt: getCurrentTimestamp()
624
+ };
625
+ const warning = existing ? await this._releaseEntryResources(existing) : undefined;
626
+ this._secrets.set(name, entry);
627
+ this._dirty = true;
628
+ return succeed({ entry, replaced: existing !== undefined, warning });
629
+ }
630
+ /**
631
+ * Retrieves the keypair for an asymmetric-keypair entry. The private key is
632
+ * loaded from {@link CryptoUtils.KeyStore.IPrivateKeyStorage} on every call —
633
+ * the keystore never caches private `CryptoKey` references between calls.
634
+ * The public key is re-imported from the vault's JWK so callers always
635
+ * receive a `CryptoKey` rather than the JWK form.
636
+ * @param name - Name of the entry
637
+ * @returns Success with `{ publicKey, privateKey }`, Failure if not found,
638
+ * locked, wrong type, no provider, or storage load failed.
639
+ * @public
640
+ */
641
+ async getKeyPair(name) {
642
+ if (!this._secrets) {
643
+ return fail('Key store is locked');
644
+ }
645
+ const entry = this._secrets.get(name);
646
+ if (!entry) {
647
+ return fail(`Secret '${name}' not found`);
648
+ }
649
+ if (entry.type !== 'asymmetric-keypair') {
650
+ return fail(`Secret '${name}' is not an asymmetric keypair (type: ${entry.type})`);
651
+ }
652
+ if (!this._privateKeyStorage) {
653
+ return fail('No private key storage configured');
654
+ }
655
+ const privateResult = await this._privateKeyStorage.load(entry.id);
656
+ if (privateResult.isFailure()) {
657
+ return fail(`Failed to load private key for '${name}': ${privateResult.message}`);
658
+ }
659
+ const publicResult = await this._cryptoProvider.importPublicKeyJwk(entry.publicKeyJwk, entry.algorithm);
660
+ /* c8 ignore next 3 - vault JWKs that previously exported cleanly are extremely unlikely to fail re-import */
661
+ if (publicResult.isFailure()) {
662
+ return fail(`Failed to re-import public key for '${name}': ${publicResult.message}`);
663
+ }
664
+ return succeed({ publicKey: publicResult.value, privateKey: privateResult.value });
665
+ }
518
666
  /**
519
667
  * Lists secret names filtered by type.
520
668
  * @param type - The secret type to filter by
@@ -554,7 +702,8 @@ export class KeyStore {
554
702
  if (oldName !== newName && this._secrets.has(newName)) {
555
703
  return fail(`Secret '${newName}' already exists`);
556
704
  }
557
- // Create new entry with new name (preserve type)
705
+ // Create new entry with new name. For asymmetric entries the spread
706
+ // preserves `id` so the storage handle survives the rename.
558
707
  const newEntry = Object.assign(Object.assign({}, entry), { name: newName });
559
708
  this._secrets.delete(oldName);
560
709
  this._secrets.set(newName, newEntry);
@@ -674,6 +823,9 @@ export class KeyStore {
674
823
  if (secretResult.isFailure()) {
675
824
  return fail(`encryptByName: ${secretResult.message}`);
676
825
  }
826
+ if (secretResult.value.type === 'asymmetric-keypair') {
827
+ return fail(`encryptByName: secret '${secretName}' is an asymmetric keypair, not symmetric key material`);
828
+ }
677
829
  return createEncryptedFile({
678
830
  content,
679
831
  secretName,
@@ -701,6 +853,9 @@ export class KeyStore {
701
853
  if (!entry) {
702
854
  return fail(`Secret '${secretName}' not found in key store`);
703
855
  }
856
+ if (entry.type === 'asymmetric-keypair') {
857
+ return fail(`Secret '${secretName}' is an asymmetric keypair, not symmetric key material`);
858
+ }
704
859
  return succeed(entry.key);
705
860
  };
706
861
  return succeed(provider);
@@ -734,13 +889,26 @@ export class KeyStore {
734
889
  // Build vault contents
735
890
  const secretEntries = {};
736
891
  for (const [name, entry] of secrets) {
737
- secretEntries[name] = {
738
- name: entry.name,
739
- type: entry.type,
740
- key: this._cryptoProvider.toBase64(entry.key),
741
- description: entry.description,
742
- createdAt: entry.createdAt
743
- };
892
+ if (entry.type === 'asymmetric-keypair') {
893
+ secretEntries[name] = {
894
+ name: entry.name,
895
+ type: entry.type,
896
+ id: entry.id,
897
+ algorithm: entry.algorithm,
898
+ publicKeyJwk: entry.publicKeyJwk,
899
+ description: entry.description,
900
+ createdAt: entry.createdAt
901
+ };
902
+ }
903
+ else {
904
+ secretEntries[name] = {
905
+ name: entry.name,
906
+ type: entry.type,
907
+ key: this._cryptoProvider.toBase64(entry.key),
908
+ description: entry.description,
909
+ createdAt: entry.createdAt
910
+ };
911
+ }
744
912
  }
745
913
  const vaultContents = {
746
914
  version: KEYSTORE_FORMAT,
@@ -780,7 +948,6 @@ export class KeyStore {
780
948
  * Shared by `unlock()` and `unlockWithKey()`.
781
949
  */
782
950
  async _decryptVault(derivedKey) {
783
- var _a;
784
951
  const keystoreFile = this._keystoreFile;
785
952
  if (keystoreFile === undefined) {
786
953
  return fail('No key store file loaded');
@@ -820,20 +987,33 @@ export class KeyStore {
820
987
  }
821
988
  const secrets = new Map();
822
989
  for (const [name, jsonEntry] of Object.entries(vaultResult.value.secrets)) {
823
- const keyBytesResult = this._cryptoProvider.fromBase64(jsonEntry.key);
824
- /* c8 ignore next 3 - error path tested but coverage intermittently missed */
825
- if (keyBytesResult.isFailure()) {
826
- return fail(`Invalid key for secret '${name}': ${keyBytesResult.message}`);
990
+ if (jsonEntry.type === 'asymmetric-keypair') {
991
+ const entry = {
992
+ name,
993
+ type: jsonEntry.type,
994
+ id: jsonEntry.id,
995
+ algorithm: jsonEntry.algorithm,
996
+ publicKeyJwk: jsonEntry.publicKeyJwk,
997
+ description: jsonEntry.description,
998
+ createdAt: jsonEntry.createdAt
999
+ };
1000
+ secrets.set(name, entry);
1001
+ }
1002
+ else {
1003
+ const keyBytesResult = this._cryptoProvider.fromBase64(jsonEntry.key);
1004
+ /* c8 ignore next 3 - error path tested but coverage intermittently missed */
1005
+ if (keyBytesResult.isFailure()) {
1006
+ return fail(`Invalid key for secret '${name}': ${keyBytesResult.message}`);
1007
+ }
1008
+ const entry = {
1009
+ name,
1010
+ type: jsonEntry.type,
1011
+ key: keyBytesResult.value,
1012
+ description: jsonEntry.description,
1013
+ createdAt: jsonEntry.createdAt
1014
+ };
1015
+ secrets.set(name, entry);
827
1016
  }
828
- const entry = {
829
- name,
830
- /* c8 ignore next 1 - backwards compatibility: old vaults may lack type field */
831
- type: (_a = jsonEntry.type) !== null && _a !== void 0 ? _a : 'encryption-key',
832
- key: keyBytesResult.value,
833
- description: jsonEntry.description,
834
- createdAt: jsonEntry.createdAt
835
- };
836
- secrets.set(name, entry);
837
1017
  }
838
1018
  // All validation passed — commit state atomically
839
1019
  this._salt = saltResult.value;
@@ -842,5 +1022,50 @@ export class KeyStore {
842
1022
  this._dirty = false;
843
1023
  return succeed(this);
844
1024
  }
1025
+ // ============================================================================
1026
+ // Private: Helpers for asymmetric flows
1027
+ // ============================================================================
1028
+ /**
1029
+ * Releases the resources held by an entry being displaced from the vault.
1030
+ * Symmetric entries get their key buffer zeroed in place. Asymmetric entries
1031
+ * have their private-key blob best-effort deleted from
1032
+ * {@link CryptoUtils.KeyStore.IPrivateKeyStorage}; if the storage call fails,
1033
+ * a warning string is returned but the displacement still proceeds — the
1034
+ * orphaned blob is left for consumer-side GC. Without a configured provider,
1035
+ * asymmetric cleanup is silently skipped.
1036
+ * @returns A warning string if storage cleanup failed, otherwise undefined.
1037
+ */
1038
+ async _releaseEntryResources(entry) {
1039
+ if (entry.type === 'asymmetric-keypair') {
1040
+ if (!this._privateKeyStorage) {
1041
+ return undefined;
1042
+ }
1043
+ const deleteResult = await this._privateKeyStorage.delete(entry.id);
1044
+ if (deleteResult.isFailure()) {
1045
+ return `Failed to delete prior storage blob for '${entry.name}' (id ${entry.id}): ${deleteResult.message}`;
1046
+ }
1047
+ return undefined;
1048
+ }
1049
+ entry.key.fill(0);
1050
+ return undefined;
1051
+ }
1052
+ /**
1053
+ * Mints a fresh UUID v4 storage handle using the crypto provider's
1054
+ * {@link CryptoUtils.ICryptoProvider.generateRandomBytes | generateRandomBytes}.
1055
+ * Random-bytes failures propagate as Failure.
1056
+ */
1057
+ _generateId() {
1058
+ return this._cryptoProvider.generateRandomBytes(16).onSuccess((bytes) => {
1059
+ // Per RFC 4122 §4.4: set version (4) and variant (10xx) bits.
1060
+ // eslint-disable-next-line no-bitwise
1061
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1062
+ // eslint-disable-next-line no-bitwise
1063
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1064
+ const hex = Array.from(bytes)
1065
+ .map((b) => b.toString(16).padStart(2, '0'))
1066
+ .join('');
1067
+ return succeed(`${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`);
1068
+ });
1069
+ }
845
1070
  }
846
1071
  //# sourceMappingURL=keyStore.js.map