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

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 (33) hide show
  1. package/dist/packlets/crypto-utils/index.browser.js +2 -0
  2. package/dist/packlets/crypto-utils/index.js +2 -0
  3. package/dist/packlets/crypto-utils/keyPairAlgorithmParams.js +47 -0
  4. package/dist/packlets/crypto-utils/keystore/converters.js +101 -9
  5. package/dist/packlets/crypto-utils/keystore/index.js +1 -0
  6. package/dist/packlets/crypto-utils/keystore/keyStore.js +271 -46
  7. package/dist/packlets/crypto-utils/keystore/model.js +22 -1
  8. package/dist/packlets/crypto-utils/keystore/privateKeyStorage.js +21 -0
  9. package/dist/packlets/crypto-utils/model.js +5 -0
  10. package/dist/packlets/crypto-utils/nodeCryptoProvider.js +44 -1
  11. package/dist/test/unit/crypto/keystore/inMemoryPrivateKeyStorage.js +78 -0
  12. package/dist/ts-extras.d.ts +577 -32
  13. package/lib/packlets/crypto-utils/index.browser.d.ts +1 -0
  14. package/lib/packlets/crypto-utils/index.browser.js +4 -1
  15. package/lib/packlets/crypto-utils/index.d.ts +1 -0
  16. package/lib/packlets/crypto-utils/index.js +4 -1
  17. package/lib/packlets/crypto-utils/keyPairAlgorithmParams.d.ts +39 -0
  18. package/lib/packlets/crypto-utils/keyPairAlgorithmParams.js +50 -0
  19. package/lib/packlets/crypto-utils/keystore/converters.d.ts +68 -6
  20. package/lib/packlets/crypto-utils/keystore/converters.js +100 -8
  21. package/lib/packlets/crypto-utils/keystore/index.d.ts +1 -0
  22. package/lib/packlets/crypto-utils/keystore/index.js +1 -0
  23. package/lib/packlets/crypto-utils/keystore/keyStore.d.ts +77 -9
  24. package/lib/packlets/crypto-utils/keystore/keyStore.js +271 -46
  25. package/lib/packlets/crypto-utils/keystore/model.d.ts +238 -19
  26. package/lib/packlets/crypto-utils/keystore/model.js +24 -2
  27. package/lib/packlets/crypto-utils/keystore/privateKeyStorage.d.ts +50 -0
  28. package/lib/packlets/crypto-utils/keystore/privateKeyStorage.js +22 -0
  29. package/lib/packlets/crypto-utils/model.d.ts +38 -0
  30. package/lib/packlets/crypto-utils/model.js +6 -1
  31. package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +26 -1
  32. package/lib/packlets/crypto-utils/nodeCryptoProvider.js +43 -0
  33. package/package.json +7 -7
@@ -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
@@ -17,6 +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
+ // Re-export so consumers can continue to access the algorithm enum via the
21
+ // CryptoUtils.KeyStore namespace alongside the rest of the keystore types.
22
+ export { allKeyPairAlgorithms } from '../model';
20
23
  /**
21
24
  * Current format version constant.
22
25
  * @public
@@ -33,11 +36,29 @@ export const DEFAULT_KEYSTORE_ITERATIONS = 600000;
33
36
  * @public
34
37
  */
35
38
  export const MIN_SALT_LENGTH = 16;
39
+ /**
40
+ * All valid symmetric secret types.
41
+ * @public
42
+ */
43
+ export const allKeyStoreSymmetricSecretTypes = [
44
+ 'encryption-key',
45
+ 'api-key'
46
+ ];
47
+ /**
48
+ * All valid asymmetric secret types.
49
+ * @public
50
+ */
51
+ export const allKeyStoreAsymmetricSecretTypes = [
52
+ 'asymmetric-keypair'
53
+ ];
36
54
  /**
37
55
  * All valid key store secret types.
38
56
  * @public
39
57
  */
40
- export const allKeyStoreSecretTypes = ['encryption-key', 'api-key'];
58
+ export const allKeyStoreSecretTypes = [
59
+ ...allKeyStoreAsymmetricSecretTypes,
60
+ ...allKeyStoreSymmetricSecretTypes
61
+ ];
41
62
  /**
42
63
  * Default PBKDF2 iterations for secret-level key derivation.
43
64
  * Lower than keystore encryption since these are used more frequently.
@@ -0,0 +1,21 @@
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
+ export {};
21
+ //# sourceMappingURL=privateKeyStorage.js.map
@@ -19,6 +19,11 @@
19
19
  // SOFTWARE.
20
20
  import * as Constants from './constants';
21
21
  export { Constants };
22
+ /**
23
+ * All valid key pair algorithms.
24
+ * @public
25
+ */
26
+ export const allKeyPairAlgorithms = ['ecdsa-p256', 'rsa-oaep-2048'];
22
27
  // ============================================================================
23
28
  // Detection Helper
24
29
  // ============================================================================
@@ -18,8 +18,9 @@
18
18
  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
19
  // SOFTWARE.
20
20
  import * as crypto from 'crypto';
21
- import { captureResult, fail, Failure, succeed, Success } from '@fgv/ts-utils';
21
+ import { captureAsyncResult, captureResult, fail, Failure, succeed, Success } from '@fgv/ts-utils';
22
22
  import * as Constants from './constants';
23
+ import { keyPairAlgorithmParams } from './keyPairAlgorithmParams';
23
24
  /**
24
25
  * Node.js implementation of {@link CryptoUtils.ICryptoProvider} using the built-in crypto module.
25
26
  * Uses AES-256-GCM for authenticated encryption.
@@ -162,6 +163,48 @@ export class NodeCryptoProvider {
162
163
  }
163
164
  return Success.with(new Uint8Array(Buffer.from(base64, 'base64')));
164
165
  }
166
+ // ============================================================================
167
+ // Asymmetric Key Operations
168
+ // ============================================================================
169
+ /**
170
+ * Generates a new asymmetric keypair using Node's WebCrypto.
171
+ * @param algorithm - The {@link CryptoUtils.KeyPairAlgorithm | algorithm} to use.
172
+ * @param extractable - Whether the resulting keys may be exported.
173
+ * @returns `Success` with the generated `CryptoKeyPair`, or `Failure` with an error.
174
+ */
175
+ async generateKeyPair(algorithm, extractable) {
176
+ const params = keyPairAlgorithmParams[algorithm];
177
+ const result = await captureAsyncResult(() => crypto.webcrypto.subtle.generateKey(params.generateKey, extractable, params.keyPairUsages));
178
+ return result.withErrorFormat((e) => `Failed to generate ${algorithm} keypair: ${e}`);
179
+ }
180
+ /**
181
+ * Exports a public `CryptoKey` as a JSON Web Key.
182
+ * @remarks
183
+ * Rejects non-public keys at runtime. WebCrypto's `exportKey('jwk', ...)`
184
+ * does not enforce public-vs-private; without this guard a caller that
185
+ * passed an extractable private key would receive its private fields
186
+ * (`d`, `p`, `q`, ...) as JWK, defeating the method's name.
187
+ * @param publicKey - Extractable public key to export.
188
+ * @returns `Success` with the JWK, or `Failure` if not a public key or if export fails.
189
+ */
190
+ async exportPublicKeyJwk(publicKey) {
191
+ if (publicKey.type !== 'public') {
192
+ return fail(`exportPublicKeyJwk requires a public CryptoKey, got '${publicKey.type}'`);
193
+ }
194
+ const result = await captureAsyncResult(() => crypto.webcrypto.subtle.exportKey('jwk', publicKey));
195
+ return result.withErrorFormat((e) => `Failed to export public key as JWK: ${e}`);
196
+ }
197
+ /**
198
+ * Imports a public-key JWK as a `CryptoKey` for the requested algorithm.
199
+ * @param jwk - The JSON Web Key produced by a prior export.
200
+ * @param algorithm - The algorithm the key was generated for.
201
+ * @returns `Success` with the imported public `CryptoKey`, or `Failure` with an error.
202
+ */
203
+ async importPublicKeyJwk(jwk, algorithm) {
204
+ const params = keyPairAlgorithmParams[algorithm];
205
+ const result = await captureAsyncResult(() => crypto.webcrypto.subtle.importKey('jwk', jwk, params.importPublicKey, true, params.publicKeyUsages));
206
+ return result.withErrorFormat((e) => `Failed to import ${algorithm} public key from JWK: ${e}`);
207
+ }
165
208
  }
166
209
  /**
167
210
  * Singleton instance of {@link CryptoUtils.NodeCryptoProvider}.