@fgv/ts-extras 5.1.0-40 → 5.1.0-41

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 (53) hide show
  1. package/dist/packlets/crypto-utils/hpkeProvider.js +34 -12
  2. package/dist/packlets/crypto-utils/hpkeProvider.js.map +1 -1
  3. package/dist/packlets/crypto-utils/index.browser.js +1 -1
  4. package/dist/packlets/crypto-utils/index.browser.js.map +1 -1
  5. package/dist/packlets/crypto-utils/index.js +1 -1
  6. package/dist/packlets/crypto-utils/index.js.map +1 -1
  7. package/dist/packlets/crypto-utils/keystore/converters.js +3 -4
  8. package/dist/packlets/crypto-utils/keystore/converters.js.map +1 -1
  9. package/dist/packlets/crypto-utils/keystore/keyStore.js +155 -16
  10. package/dist/packlets/crypto-utils/keystore/keyStore.js.map +1 -1
  11. package/dist/packlets/crypto-utils/keystore/model.js +8 -3
  12. package/dist/packlets/crypto-utils/keystore/model.js.map +1 -1
  13. package/dist/packlets/crypto-utils/model.js.map +1 -1
  14. package/dist/packlets/crypto-utils/nodeCryptoProvider.js +66 -0
  15. package/dist/packlets/crypto-utils/nodeCryptoProvider.js.map +1 -1
  16. package/dist/packlets/crypto-utils/spkiHelpers.js +31 -0
  17. package/dist/packlets/crypto-utils/spkiHelpers.js.map +1 -1
  18. package/dist/ts-extras.d.ts +289 -7
  19. package/lib/packlets/crypto-utils/hpkeProvider.d.ts +11 -3
  20. package/lib/packlets/crypto-utils/hpkeProvider.d.ts.map +1 -1
  21. package/lib/packlets/crypto-utils/hpkeProvider.js +34 -12
  22. package/lib/packlets/crypto-utils/hpkeProvider.js.map +1 -1
  23. package/lib/packlets/crypto-utils/index.browser.d.ts +1 -1
  24. package/lib/packlets/crypto-utils/index.browser.d.ts.map +1 -1
  25. package/lib/packlets/crypto-utils/index.browser.js +2 -1
  26. package/lib/packlets/crypto-utils/index.browser.js.map +1 -1
  27. package/lib/packlets/crypto-utils/index.d.ts +1 -1
  28. package/lib/packlets/crypto-utils/index.d.ts.map +1 -1
  29. package/lib/packlets/crypto-utils/index.js +2 -1
  30. package/lib/packlets/crypto-utils/index.js.map +1 -1
  31. package/lib/packlets/crypto-utils/keystore/converters.d.ts.map +1 -1
  32. package/lib/packlets/crypto-utils/keystore/converters.js +2 -3
  33. package/lib/packlets/crypto-utils/keystore/converters.js.map +1 -1
  34. package/lib/packlets/crypto-utils/keystore/keyStore.d.ts +68 -3
  35. package/lib/packlets/crypto-utils/keystore/keyStore.d.ts.map +1 -1
  36. package/lib/packlets/crypto-utils/keystore/keyStore.js +154 -15
  37. package/lib/packlets/crypto-utils/keystore/keyStore.js.map +1 -1
  38. package/lib/packlets/crypto-utils/keystore/model.d.ts +75 -2
  39. package/lib/packlets/crypto-utils/keystore/model.d.ts.map +1 -1
  40. package/lib/packlets/crypto-utils/keystore/model.js +9 -4
  41. package/lib/packlets/crypto-utils/keystore/model.js.map +1 -1
  42. package/lib/packlets/crypto-utils/model.d.ts +89 -0
  43. package/lib/packlets/crypto-utils/model.d.ts.map +1 -1
  44. package/lib/packlets/crypto-utils/model.js.map +1 -1
  45. package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +25 -1
  46. package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts.map +1 -1
  47. package/lib/packlets/crypto-utils/nodeCryptoProvider.js +66 -0
  48. package/lib/packlets/crypto-utils/nodeCryptoProvider.js.map +1 -1
  49. package/lib/packlets/crypto-utils/spkiHelpers.d.ts +15 -0
  50. package/lib/packlets/crypto-utils/spkiHelpers.d.ts.map +1 -1
  51. package/lib/packlets/crypto-utils/spkiHelpers.js +32 -0
  52. package/lib/packlets/crypto-utils/spkiHelpers.js.map +1 -1
  53. package/package.json +7 -7
@@ -56,6 +56,7 @@ exports.KeyStore = void 0;
56
56
  const ts_utils_1 = require("@fgv/ts-utils");
57
57
  const Constants = __importStar(require("../constants"));
58
58
  const encryptedFile_1 = require("../encryptedFile");
59
+ const keyPairAlgorithmParams_1 = require("../keyPairAlgorithmParams");
59
60
  const model_1 = require("../model");
60
61
  const model_2 = require("./model");
61
62
  const converters_1 = require("./converters");
@@ -65,6 +66,11 @@ const converters_1 = require("./converters");
65
66
  function getCurrentTimestamp() {
66
67
  return new Date().toISOString();
67
68
  }
69
+ // WebCrypto key usages that only ever apply to a public key. Filtering these
70
+ // out of the keypair usages yields the usages valid for the private half, which
71
+ // `crypto.subtle.importKey` requires when re-importing a private JWK. Mirrors
72
+ // the same list in `EncryptedFilePrivateKeyStorage`.
73
+ const PUBLIC_ONLY_USAGES = ['verify', 'encrypt', 'wrapKey'];
68
74
  // ============================================================================
69
75
  // KeyStore Class
70
76
  // ============================================================================
@@ -97,6 +103,20 @@ function getCurrentTimestamp() {
97
103
  * const encryptionConfig = keystore2.getEncryptionConfig().orThrow();
98
104
  * ```
99
105
  *
106
+ * @remarks
107
+ * SECURITY — private-key escrow. By default an asymmetric keypair's private
108
+ * key lives only in the per-device {@link CryptoUtils.KeyStore.IPrivateKeyStorage}
109
+ * backend; the vault carries only the public JWK, so a vault recovered on a new
110
+ * device reconstitutes with no private keys (a lost device is a lost identity).
111
+ * `addKeyPair(name, { escrow: true })` opts into carrying an encrypted private-key
112
+ * copy inside the vault ciphertext, so the vault file plus the master password can
113
+ * recover the identity on a fresh device via `getKeyPair(name, { rehydrate: true })`.
114
+ *
115
+ * When escrow is used, **the master password becomes the sole gate** protecting a
116
+ * private signing/decryption key that was previously unrecoverable from the vault.
117
+ * Configure escrow-bearing stores with a strong KDF — an Argon2id-derived master
118
+ * key or a high-iteration PBKDF2 count — and treat the vault file accordingly.
119
+ *
100
120
  * @public
101
121
  */
102
122
  class KeyStore {
@@ -322,6 +342,12 @@ class KeyStore {
322
342
  * Gets a secret by name. Returns the {@link CryptoUtils.KeyStore.IKeyStoreEntry | discriminated union}
323
343
  * — callers must check `entry.type` before accessing `key`/`id` since asymmetric
324
344
  * entries carry no raw key material.
345
+ *
346
+ * SECURITY: for an escrow-enabled asymmetric-keypair entry the returned object
347
+ * carries `escrowedPrivateKeyJwk` in cleartext (same custody class as
348
+ * `publicKeyJwk`, gated by the same unlock) — do not log or serialize a
349
+ * `getSecret()` result casually.
350
+ *
325
351
  * @param name - Name of the secret
326
352
  * @returns Success with secret entry, Failure if not found or locked
327
353
  * @public
@@ -809,8 +835,16 @@ class KeyStore {
809
835
  if (options.extractable === false && backendExtractable) {
810
836
  return (0, ts_utils_1.fail)(`Cannot create non-extractable keypair for '${name}': storage backend does not support non-extractable keys`);
811
837
  }
812
- const extractable = (_a = options.extractable) !== null && _a !== void 0 ? _a : backendExtractable;
813
- const keyPairResult = await this._cryptoProvider.generateKeyPair(options.algorithm, extractable);
838
+ // Extractability of the LIVE stored key. Escrow is orthogonal it never
839
+ // changes this; the escrow copy is captured separately from a transient
840
+ // extractable key.
841
+ const liveExtractable = (_a = options.extractable) !== null && _a !== void 0 ? _a : backendExtractable;
842
+ // For escrow we must be able to export the private key to JWK, which only
843
+ // works on an extractable key, so the transient keypair is generated
844
+ // extractable regardless of `liveExtractable`. Without escrow it is
845
+ // generated directly at the live setting.
846
+ const generateExtractable = options.escrow === true ? true : liveExtractable;
847
+ const keyPairResult = await this._cryptoProvider.generateKeyPair(options.algorithm, generateExtractable);
814
848
  /* c8 ignore next 3 - crypto provider errors covered in nodeCryptoProvider tests; cannot be triggered here without mocking */
815
849
  if (keyPairResult.isFailure()) {
816
850
  return (0, ts_utils_1.fail)(`Failed to generate keypair for '${name}': ${keyPairResult.message}`);
@@ -821,6 +855,34 @@ class KeyStore {
821
855
  if (jwkResult.isFailure()) {
822
856
  return (0, ts_utils_1.fail)(`Failed to export public key for '${name}': ${jwkResult.message}`);
823
857
  }
858
+ // Escrow (opt-in). The bare private key exists only as three local
859
+ // artifacts, none of which is returned or logged: (1) the transient
860
+ // extractable `privateKey` above; (2) the exported `escrowedPrivateKeyJwk`,
861
+ // which is carried in the vault entry (same custody class as the public
862
+ // JWK); (3) a freshly re-imported non-extractable key handed to `store()`
863
+ // when the live copy must be non-extractable. When the live copy is
864
+ // extractable the transient key IS the live copy and is stored directly.
865
+ let escrowedPrivateKeyJwk;
866
+ let keyToStore = privateKey;
867
+ if (options.escrow === true) {
868
+ // Chain the export → (conditional) non-extractable re-import so the
869
+ // failure dispatch lives in ts-utils rather than in local branch nodes.
870
+ // When the live copy is extractable the transient key IS the live copy;
871
+ // otherwise a freshly re-imported non-extractable key is stored instead.
872
+ const escrowPrepResult = await (await this._exportPrivateKeyJwk(privateKey, name)).thenOnSuccess(async (jwk) => {
873
+ if (liveExtractable) {
874
+ return (0, ts_utils_1.succeed)({ jwk, keyToStore: privateKey });
875
+ }
876
+ return (await this._importEscrowedPrivateKey(jwk, options.algorithm, false))
877
+ .withErrorFormat((msg) => `Failed to prepare non-extractable key for '${name}': ${msg}`)
878
+ .onSuccess((reimported) => (0, ts_utils_1.succeed)({ jwk, keyToStore: reimported }));
879
+ });
880
+ /* c8 ignore next 3 - escrowPrepResult only fails if exporting or re-importing a freshly-generated valid key fails, which a healthy provider cannot do (same untestable class as the keyPairResult/jwkResult/idResult guards); the import-failure path itself is covered via getKeyPair rehydration of a malformed escrow JWK */
881
+ if (escrowPrepResult.isFailure()) {
882
+ return (0, ts_utils_1.fail)(escrowPrepResult.message);
883
+ }
884
+ ({ jwk: escrowedPrivateKeyJwk, keyToStore } = escrowPrepResult.value);
885
+ }
824
886
  const idResult = this._generateId();
825
887
  /* c8 ignore next 3 - random-bytes failure is hard to trigger with a healthy provider */
826
888
  if (idResult.isFailure()) {
@@ -828,7 +890,7 @@ class KeyStore {
828
890
  }
829
891
  const id = idResult.value;
830
892
  // Storage-first: write the private key before committing the vault entry.
831
- const storeResult = await this._privateKeyStorage.store(id, privateKey);
893
+ const storeResult = await this._privateKeyStorage.store(id, keyToStore);
832
894
  if (storeResult.isFailure()) {
833
895
  return (0, ts_utils_1.fail)(`Failed to persist private key for '${name}': ${storeResult.message}`);
834
896
  }
@@ -838,6 +900,7 @@ class KeyStore {
838
900
  id,
839
901
  algorithm: options.algorithm,
840
902
  publicKeyJwk: jwkResult.value,
903
+ escrowedPrivateKeyJwk,
841
904
  description: options.description,
842
905
  createdAt: getCurrentTimestamp()
843
906
  };
@@ -852,12 +915,23 @@ class KeyStore {
852
915
  * the keystore never caches private `CryptoKey` references between calls.
853
916
  * The public key is re-imported from the vault's JWK so callers always
854
917
  * receive a `CryptoKey` rather than the JWK form.
918
+ *
919
+ * With `options.rehydrate: true`, if the storage backend holds no blob for
920
+ * the entry's `id` and the entry carries an `escrowedPrivateKeyJwk` (see
921
+ * `addKeyPair(name, { escrow: true })`), the escrowed JWK is imported and
922
+ * stored under the entry's `id` before being returned — recovering the
923
+ * private key on a fresh device from the vault plus master password. This is
924
+ * fill-a-gap only: an existing storage blob is never overwritten.
925
+ *
855
926
  * @param name - Name of the entry
927
+ * @param options - Optional {@link CryptoUtils.KeyStore.IGetKeyPairOptions}
928
+ * (currently the `rehydrate` escrow-recovery flag).
856
929
  * @returns Success with `{ publicKey, privateKey }`, Failure if not found,
857
- * locked, wrong type, no provider, or storage load failed.
930
+ * locked, wrong type, no provider, or storage load (and any escrow
931
+ * rehydration) failed.
858
932
  * @public
859
933
  */
860
- async getKeyPair(name) {
934
+ async getKeyPair(name, options) {
861
935
  if (!this._secrets) {
862
936
  return (0, ts_utils_1.fail)('Key store is locked');
863
937
  }
@@ -871,16 +945,9 @@ class KeyStore {
871
945
  if (!this._privateKeyStorage) {
872
946
  return (0, ts_utils_1.fail)('No private key storage configured');
873
947
  }
874
- const privateResult = await this._privateKeyStorage.load(entry.id);
875
- if (privateResult.isFailure()) {
876
- return (0, ts_utils_1.fail)(`Failed to load private key for '${name}': ${privateResult.message}`);
877
- }
878
- const publicResult = await this._cryptoProvider.importPublicKeyJwk(entry.publicKeyJwk, entry.algorithm);
879
- /* c8 ignore next 3 - vault JWKs that previously exported cleanly are extremely unlikely to fail re-import */
880
- if (publicResult.isFailure()) {
881
- return (0, ts_utils_1.fail)(`Failed to re-import public key for '${name}': ${publicResult.message}`);
882
- }
883
- return (0, ts_utils_1.succeed)({ publicKey: publicResult.value, privateKey: privateResult.value });
948
+ return (await this._loadOrRehydratePrivateKey(name, entry, options)).thenOnSuccess(async (privateKey) => (await this._cryptoProvider.importPublicKeyJwk(entry.publicKeyJwk, entry.algorithm))
949
+ .withErrorFormat((msg) => `Failed to re-import public key for '${name}': ${msg}`)
950
+ .onSuccess((publicKey) => (0, ts_utils_1.succeed)({ publicKey, privateKey })));
884
951
  }
885
952
  /**
886
953
  * Lists secret names filtered by type.
@@ -1115,6 +1182,7 @@ class KeyStore {
1115
1182
  id: entry.id,
1116
1183
  algorithm: entry.algorithm,
1117
1184
  publicKeyJwk: entry.publicKeyJwk,
1185
+ escrowedPrivateKeyJwk: entry.escrowedPrivateKeyJwk,
1118
1186
  description: entry.description,
1119
1187
  createdAt: entry.createdAt
1120
1188
  };
@@ -1214,6 +1282,7 @@ class KeyStore {
1214
1282
  id: jsonEntry.id,
1215
1283
  algorithm: jsonEntry.algorithm,
1216
1284
  publicKeyJwk: jsonEntry.publicKeyJwk,
1285
+ escrowedPrivateKeyJwk: jsonEntry.escrowedPrivateKeyJwk,
1217
1286
  description: jsonEntry.description,
1218
1287
  createdAt: jsonEntry.createdAt
1219
1288
  };
@@ -1269,6 +1338,76 @@ class KeyStore {
1269
1338
  entry.key.fill(0);
1270
1339
  return undefined;
1271
1340
  }
1341
+ /**
1342
+ * Exports the transient extractable private `CryptoKey` to a JWK for escrow,
1343
+ * via WebCrypto's `exportKey('jwk', ...)` (cross-runtime through
1344
+ * `globalThis.crypto.subtle`). The result is carried in the vault entry — the
1345
+ * same custody class as the public JWK — and is never returned from a public
1346
+ * method nor logged.
1347
+ */
1348
+ async _exportPrivateKeyJwk(privateKey, name) {
1349
+ return (0, ts_utils_1.captureAsyncResult)(() => globalThis.crypto.subtle.exportKey('jwk', privateKey)).withErrorFormat((msg) => `Failed to export private key for escrow of '${name}': ${msg}`);
1350
+ }
1351
+ /**
1352
+ * Re-imports an escrowed private-key JWK as a `CryptoKey` for `algorithm`
1353
+ * with the requested extractability. The WebCrypto JWK-import descriptor is
1354
+ * shared between the public and private halves for every supported algorithm,
1355
+ * so `IKeyPairAlgorithmParams.importPublicKey` is reused; the private/public
1356
+ * distinction is carried by the requested usages (see
1357
+ * {@link KeyStore._privateKeyUsagesFor}). Cross-runtime through
1358
+ * `globalThis.crypto.subtle`.
1359
+ */
1360
+ async _importEscrowedPrivateKey(jwk, algorithm, extractable) {
1361
+ const params = keyPairAlgorithmParams_1.keyPairAlgorithmParams[algorithm];
1362
+ const usages = KeyStore._privateKeyUsagesFor(jwk, params);
1363
+ return (0, ts_utils_1.captureAsyncResult)(() => globalThis.crypto.subtle.importKey('jwk', jwk, params.importPublicKey, extractable, usages)).withErrorFormat((msg) => `Failed to import escrowed private key: ${msg}`);
1364
+ }
1365
+ /**
1366
+ * Loads the private key for `entry` from storage, or — when
1367
+ * `options.rehydrate` is set, storage holds no blob, and the entry carries an
1368
+ * escrowed JWK — imports the escrowed key, persists it under the entry's `id`
1369
+ * (fill-a-gap; never overwrites an existing blob), and returns it.
1370
+ */
1371
+ async _loadOrRehydratePrivateKey(name, entry, options) {
1372
+ // Non-undefined by getKeyPair's precondition check.
1373
+ const storage = this._privateKeyStorage;
1374
+ const loadResult = await storage.load(entry.id);
1375
+ if (loadResult.isSuccess()) {
1376
+ return (0, ts_utils_1.succeed)(loadResult.value);
1377
+ }
1378
+ if ((options === null || options === void 0 ? void 0 : options.rehydrate) !== true || entry.escrowedPrivateKeyJwk === undefined) {
1379
+ return (0, ts_utils_1.fail)(`Failed to load private key for '${name}': ${loadResult.message}`);
1380
+ }
1381
+ // Fill-a-gap recovery: import the escrowed key non-extractable where the
1382
+ // backend supports it (extractable otherwise, since a JWK-round-tripping
1383
+ // backend cannot store a non-extractable key), then persist it.
1384
+ const backendExtractable = !storage.supportsNonExtractable;
1385
+ const importResult = await this._importEscrowedPrivateKey(entry.escrowedPrivateKeyJwk, entry.algorithm, backendExtractable);
1386
+ if (importResult.isFailure()) {
1387
+ return (0, ts_utils_1.fail)(`Failed to rehydrate private key for '${name}' from escrow: ${importResult.message}`);
1388
+ }
1389
+ const storeResult = await storage.store(entry.id, importResult.value);
1390
+ if (storeResult.isFailure()) {
1391
+ return (0, ts_utils_1.fail)(`Failed to persist rehydrated private key for '${name}': ${storeResult.message}`);
1392
+ }
1393
+ return (0, ts_utils_1.succeed)(importResult.value);
1394
+ }
1395
+ /**
1396
+ * Computes the key usages to request when importing an escrowed private JWK.
1397
+ * Mirrors `EncryptedFilePrivateKeyStorage`: intersect the algorithm's private
1398
+ * usages (its keypair usages minus the public-only ones) with the JWK's
1399
+ * recorded `key_ops` so we request exactly the operations the stored key
1400
+ * supports; fall back to the algorithm's private usages when `key_ops` is
1401
+ * absent.
1402
+ */
1403
+ static _privateKeyUsagesFor(jwk, params) {
1404
+ const privateUsages = params.keyPairUsages.filter((usage) => !PUBLIC_ONLY_USAGES.includes(usage));
1405
+ const keyOps = jwk.key_ops;
1406
+ if (keyOps === undefined) {
1407
+ return [...privateUsages];
1408
+ }
1409
+ return privateUsages.filter((usage) => keyOps.includes(usage));
1410
+ }
1272
1411
  /**
1273
1412
  * Constant-time byte comparison. Returns false immediately for length
1274
1413
  * mismatch (length is not secret); for equal-length inputs, walks the full