@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.
- package/dist/packlets/crypto-utils/index.browser.js +2 -0
- package/dist/packlets/crypto-utils/index.js +2 -0
- package/dist/packlets/crypto-utils/keyPairAlgorithmParams.js +47 -0
- package/dist/packlets/crypto-utils/keystore/converters.js +101 -9
- package/dist/packlets/crypto-utils/keystore/index.js +1 -0
- package/dist/packlets/crypto-utils/keystore/keyStore.js +271 -46
- package/dist/packlets/crypto-utils/keystore/model.js +22 -1
- package/dist/packlets/crypto-utils/keystore/privateKeyStorage.js +21 -0
- package/dist/packlets/crypto-utils/model.js +5 -0
- package/dist/packlets/crypto-utils/nodeCryptoProvider.js +44 -1
- package/dist/test/unit/crypto/keystore/inMemoryPrivateKeyStorage.js +78 -0
- package/dist/ts-extras.d.ts +577 -32
- package/lib/packlets/crypto-utils/index.browser.d.ts +1 -0
- package/lib/packlets/crypto-utils/index.browser.js +4 -1
- package/lib/packlets/crypto-utils/index.d.ts +1 -0
- package/lib/packlets/crypto-utils/index.js +4 -1
- package/lib/packlets/crypto-utils/keyPairAlgorithmParams.d.ts +39 -0
- package/lib/packlets/crypto-utils/keyPairAlgorithmParams.js +50 -0
- package/lib/packlets/crypto-utils/keystore/converters.d.ts +68 -6
- package/lib/packlets/crypto-utils/keystore/converters.js +100 -8
- package/lib/packlets/crypto-utils/keystore/index.d.ts +1 -0
- package/lib/packlets/crypto-utils/keystore/index.js +1 -0
- package/lib/packlets/crypto-utils/keystore/keyStore.d.ts +77 -9
- package/lib/packlets/crypto-utils/keystore/keyStore.js +271 -46
- package/lib/packlets/crypto-utils/keystore/model.d.ts +238 -19
- package/lib/packlets/crypto-utils/keystore/model.js +24 -2
- package/lib/packlets/crypto-utils/keystore/privateKeyStorage.d.ts +50 -0
- package/lib/packlets/crypto-utils/keystore/privateKeyStorage.js +22 -0
- package/lib/packlets/crypto-utils/model.d.ts +38 -0
- package/lib/packlets/crypto-utils/model.js +6 -1
- package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +26 -1
- package/lib/packlets/crypto-utils/nodeCryptoProvider.js +43 -0
- 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
|
|
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.
|
|
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
|
|
367
|
-
if (
|
|
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:
|
|
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
|
|
407
|
-
if (
|
|
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:
|
|
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
|
-
//
|
|
457
|
-
|
|
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
|
-
|
|
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
|
|
482
|
-
if (
|
|
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:
|
|
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
|
|
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
|
-
|
|
738
|
-
name
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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 = [
|
|
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}.
|