@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
@@ -17,9 +17,10 @@
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 { captureResult, fail, succeed } from '@fgv/ts-utils';
20
+ import { captureAsyncResult, captureResult, fail, succeed } from '@fgv/ts-utils';
21
21
  import * as Constants from '../constants';
22
22
  import { createEncryptedFile } from '../encryptedFile';
23
+ import { keyPairAlgorithmParams } from '../keyPairAlgorithmParams';
23
24
  import { ARGON2ID_OWASP_MIN } from '../model';
24
25
  import { DEFAULT_KEYSTORE_ITERATIONS, DEFAULT_SECRET_ITERATIONS, KEYSTORE_FORMAT, MIN_SALT_LENGTH } from './model';
25
26
  import { keystoreFile, keystoreVaultContents } from './converters';
@@ -29,6 +30,11 @@ import { keystoreFile, keystoreVaultContents } from './converters';
29
30
  function getCurrentTimestamp() {
30
31
  return new Date().toISOString();
31
32
  }
33
+ // WebCrypto key usages that only ever apply to a public key. Filtering these
34
+ // out of the keypair usages yields the usages valid for the private half, which
35
+ // `crypto.subtle.importKey` requires when re-importing a private JWK. Mirrors
36
+ // the same list in `EncryptedFilePrivateKeyStorage`.
37
+ const PUBLIC_ONLY_USAGES = ['verify', 'encrypt', 'wrapKey'];
32
38
  // ============================================================================
33
39
  // KeyStore Class
34
40
  // ============================================================================
@@ -61,6 +67,20 @@ function getCurrentTimestamp() {
61
67
  * const encryptionConfig = keystore2.getEncryptionConfig().orThrow();
62
68
  * ```
63
69
  *
70
+ * @remarks
71
+ * SECURITY — private-key escrow. By default an asymmetric keypair's private
72
+ * key lives only in the per-device {@link CryptoUtils.KeyStore.IPrivateKeyStorage}
73
+ * backend; the vault carries only the public JWK, so a vault recovered on a new
74
+ * device reconstitutes with no private keys (a lost device is a lost identity).
75
+ * `addKeyPair(name, { escrow: true })` opts into carrying an encrypted private-key
76
+ * copy inside the vault ciphertext, so the vault file plus the master password can
77
+ * recover the identity on a fresh device via `getKeyPair(name, { rehydrate: true })`.
78
+ *
79
+ * When escrow is used, **the master password becomes the sole gate** protecting a
80
+ * private signing/decryption key that was previously unrecoverable from the vault.
81
+ * Configure escrow-bearing stores with a strong KDF — an Argon2id-derived master
82
+ * key or a high-iteration PBKDF2 count — and treat the vault file accordingly.
83
+ *
64
84
  * @public
65
85
  */
66
86
  export class KeyStore {
@@ -286,6 +306,12 @@ export class KeyStore {
286
306
  * Gets a secret by name. Returns the {@link CryptoUtils.KeyStore.IKeyStoreEntry | discriminated union}
287
307
  * — callers must check `entry.type` before accessing `key`/`id` since asymmetric
288
308
  * entries carry no raw key material.
309
+ *
310
+ * SECURITY: for an escrow-enabled asymmetric-keypair entry the returned object
311
+ * carries `escrowedPrivateKeyJwk` in cleartext (same custody class as
312
+ * `publicKeyJwk`, gated by the same unlock) — do not log or serialize a
313
+ * `getSecret()` result casually.
314
+ *
289
315
  * @param name - Name of the secret
290
316
  * @returns Success with secret entry, Failure if not found or locked
291
317
  * @public
@@ -773,8 +799,16 @@ export class KeyStore {
773
799
  if (options.extractable === false && backendExtractable) {
774
800
  return fail(`Cannot create non-extractable keypair for '${name}': storage backend does not support non-extractable keys`);
775
801
  }
776
- const extractable = (_a = options.extractable) !== null && _a !== void 0 ? _a : backendExtractable;
777
- const keyPairResult = await this._cryptoProvider.generateKeyPair(options.algorithm, extractable);
802
+ // Extractability of the LIVE stored key. Escrow is orthogonal it never
803
+ // changes this; the escrow copy is captured separately from a transient
804
+ // extractable key.
805
+ const liveExtractable = (_a = options.extractable) !== null && _a !== void 0 ? _a : backendExtractable;
806
+ // For escrow we must be able to export the private key to JWK, which only
807
+ // works on an extractable key, so the transient keypair is generated
808
+ // extractable regardless of `liveExtractable`. Without escrow it is
809
+ // generated directly at the live setting.
810
+ const generateExtractable = options.escrow === true ? true : liveExtractable;
811
+ const keyPairResult = await this._cryptoProvider.generateKeyPair(options.algorithm, generateExtractable);
778
812
  /* c8 ignore next 3 - crypto provider errors covered in nodeCryptoProvider tests; cannot be triggered here without mocking */
779
813
  if (keyPairResult.isFailure()) {
780
814
  return fail(`Failed to generate keypair for '${name}': ${keyPairResult.message}`);
@@ -785,6 +819,34 @@ export class KeyStore {
785
819
  if (jwkResult.isFailure()) {
786
820
  return fail(`Failed to export public key for '${name}': ${jwkResult.message}`);
787
821
  }
822
+ // Escrow (opt-in). The bare private key exists only as three local
823
+ // artifacts, none of which is returned or logged: (1) the transient
824
+ // extractable `privateKey` above; (2) the exported `escrowedPrivateKeyJwk`,
825
+ // which is carried in the vault entry (same custody class as the public
826
+ // JWK); (3) a freshly re-imported non-extractable key handed to `store()`
827
+ // when the live copy must be non-extractable. When the live copy is
828
+ // extractable the transient key IS the live copy and is stored directly.
829
+ let escrowedPrivateKeyJwk;
830
+ let keyToStore = privateKey;
831
+ if (options.escrow === true) {
832
+ // Chain the export → (conditional) non-extractable re-import so the
833
+ // failure dispatch lives in ts-utils rather than in local branch nodes.
834
+ // When the live copy is extractable the transient key IS the live copy;
835
+ // otherwise a freshly re-imported non-extractable key is stored instead.
836
+ const escrowPrepResult = await (await this._exportPrivateKeyJwk(privateKey, name)).thenOnSuccess(async (jwk) => {
837
+ if (liveExtractable) {
838
+ return succeed({ jwk, keyToStore: privateKey });
839
+ }
840
+ return (await this._importEscrowedPrivateKey(jwk, options.algorithm, false))
841
+ .withErrorFormat((msg) => `Failed to prepare non-extractable key for '${name}': ${msg}`)
842
+ .onSuccess((reimported) => succeed({ jwk, keyToStore: reimported }));
843
+ });
844
+ /* 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 */
845
+ if (escrowPrepResult.isFailure()) {
846
+ return fail(escrowPrepResult.message);
847
+ }
848
+ ({ jwk: escrowedPrivateKeyJwk, keyToStore } = escrowPrepResult.value);
849
+ }
788
850
  const idResult = this._generateId();
789
851
  /* c8 ignore next 3 - random-bytes failure is hard to trigger with a healthy provider */
790
852
  if (idResult.isFailure()) {
@@ -792,7 +854,7 @@ export class KeyStore {
792
854
  }
793
855
  const id = idResult.value;
794
856
  // Storage-first: write the private key before committing the vault entry.
795
- const storeResult = await this._privateKeyStorage.store(id, privateKey);
857
+ const storeResult = await this._privateKeyStorage.store(id, keyToStore);
796
858
  if (storeResult.isFailure()) {
797
859
  return fail(`Failed to persist private key for '${name}': ${storeResult.message}`);
798
860
  }
@@ -802,6 +864,7 @@ export class KeyStore {
802
864
  id,
803
865
  algorithm: options.algorithm,
804
866
  publicKeyJwk: jwkResult.value,
867
+ escrowedPrivateKeyJwk,
805
868
  description: options.description,
806
869
  createdAt: getCurrentTimestamp()
807
870
  };
@@ -816,12 +879,23 @@ export class KeyStore {
816
879
  * the keystore never caches private `CryptoKey` references between calls.
817
880
  * The public key is re-imported from the vault's JWK so callers always
818
881
  * receive a `CryptoKey` rather than the JWK form.
882
+ *
883
+ * With `options.rehydrate: true`, if the storage backend holds no blob for
884
+ * the entry's `id` and the entry carries an `escrowedPrivateKeyJwk` (see
885
+ * `addKeyPair(name, { escrow: true })`), the escrowed JWK is imported and
886
+ * stored under the entry's `id` before being returned — recovering the
887
+ * private key on a fresh device from the vault plus master password. This is
888
+ * fill-a-gap only: an existing storage blob is never overwritten.
889
+ *
819
890
  * @param name - Name of the entry
891
+ * @param options - Optional {@link CryptoUtils.KeyStore.IGetKeyPairOptions}
892
+ * (currently the `rehydrate` escrow-recovery flag).
820
893
  * @returns Success with `{ publicKey, privateKey }`, Failure if not found,
821
- * locked, wrong type, no provider, or storage load failed.
894
+ * locked, wrong type, no provider, or storage load (and any escrow
895
+ * rehydration) failed.
822
896
  * @public
823
897
  */
824
- async getKeyPair(name) {
898
+ async getKeyPair(name, options) {
825
899
  if (!this._secrets) {
826
900
  return fail('Key store is locked');
827
901
  }
@@ -835,16 +909,9 @@ export class KeyStore {
835
909
  if (!this._privateKeyStorage) {
836
910
  return fail('No private key storage configured');
837
911
  }
838
- const privateResult = await this._privateKeyStorage.load(entry.id);
839
- if (privateResult.isFailure()) {
840
- return fail(`Failed to load private key for '${name}': ${privateResult.message}`);
841
- }
842
- const publicResult = await this._cryptoProvider.importPublicKeyJwk(entry.publicKeyJwk, entry.algorithm);
843
- /* c8 ignore next 3 - vault JWKs that previously exported cleanly are extremely unlikely to fail re-import */
844
- if (publicResult.isFailure()) {
845
- return fail(`Failed to re-import public key for '${name}': ${publicResult.message}`);
846
- }
847
- return succeed({ publicKey: publicResult.value, privateKey: privateResult.value });
912
+ return (await this._loadOrRehydratePrivateKey(name, entry, options)).thenOnSuccess(async (privateKey) => (await this._cryptoProvider.importPublicKeyJwk(entry.publicKeyJwk, entry.algorithm))
913
+ .withErrorFormat((msg) => `Failed to re-import public key for '${name}': ${msg}`)
914
+ .onSuccess((publicKey) => succeed({ publicKey, privateKey })));
848
915
  }
849
916
  /**
850
917
  * Lists secret names filtered by type.
@@ -1079,6 +1146,7 @@ export class KeyStore {
1079
1146
  id: entry.id,
1080
1147
  algorithm: entry.algorithm,
1081
1148
  publicKeyJwk: entry.publicKeyJwk,
1149
+ escrowedPrivateKeyJwk: entry.escrowedPrivateKeyJwk,
1082
1150
  description: entry.description,
1083
1151
  createdAt: entry.createdAt
1084
1152
  };
@@ -1178,6 +1246,7 @@ export class KeyStore {
1178
1246
  id: jsonEntry.id,
1179
1247
  algorithm: jsonEntry.algorithm,
1180
1248
  publicKeyJwk: jsonEntry.publicKeyJwk,
1249
+ escrowedPrivateKeyJwk: jsonEntry.escrowedPrivateKeyJwk,
1181
1250
  description: jsonEntry.description,
1182
1251
  createdAt: jsonEntry.createdAt
1183
1252
  };
@@ -1233,6 +1302,76 @@ export class KeyStore {
1233
1302
  entry.key.fill(0);
1234
1303
  return undefined;
1235
1304
  }
1305
+ /**
1306
+ * Exports the transient extractable private `CryptoKey` to a JWK for escrow,
1307
+ * via WebCrypto's `exportKey('jwk', ...)` (cross-runtime through
1308
+ * `globalThis.crypto.subtle`). The result is carried in the vault entry — the
1309
+ * same custody class as the public JWK — and is never returned from a public
1310
+ * method nor logged.
1311
+ */
1312
+ async _exportPrivateKeyJwk(privateKey, name) {
1313
+ return captureAsyncResult(() => globalThis.crypto.subtle.exportKey('jwk', privateKey)).withErrorFormat((msg) => `Failed to export private key for escrow of '${name}': ${msg}`);
1314
+ }
1315
+ /**
1316
+ * Re-imports an escrowed private-key JWK as a `CryptoKey` for `algorithm`
1317
+ * with the requested extractability. The WebCrypto JWK-import descriptor is
1318
+ * shared between the public and private halves for every supported algorithm,
1319
+ * so `IKeyPairAlgorithmParams.importPublicKey` is reused; the private/public
1320
+ * distinction is carried by the requested usages (see
1321
+ * {@link KeyStore._privateKeyUsagesFor}). Cross-runtime through
1322
+ * `globalThis.crypto.subtle`.
1323
+ */
1324
+ async _importEscrowedPrivateKey(jwk, algorithm, extractable) {
1325
+ const params = keyPairAlgorithmParams[algorithm];
1326
+ const usages = KeyStore._privateKeyUsagesFor(jwk, params);
1327
+ return captureAsyncResult(() => globalThis.crypto.subtle.importKey('jwk', jwk, params.importPublicKey, extractable, usages)).withErrorFormat((msg) => `Failed to import escrowed private key: ${msg}`);
1328
+ }
1329
+ /**
1330
+ * Loads the private key for `entry` from storage, or — when
1331
+ * `options.rehydrate` is set, storage holds no blob, and the entry carries an
1332
+ * escrowed JWK — imports the escrowed key, persists it under the entry's `id`
1333
+ * (fill-a-gap; never overwrites an existing blob), and returns it.
1334
+ */
1335
+ async _loadOrRehydratePrivateKey(name, entry, options) {
1336
+ // Non-undefined by getKeyPair's precondition check.
1337
+ const storage = this._privateKeyStorage;
1338
+ const loadResult = await storage.load(entry.id);
1339
+ if (loadResult.isSuccess()) {
1340
+ return succeed(loadResult.value);
1341
+ }
1342
+ if ((options === null || options === void 0 ? void 0 : options.rehydrate) !== true || entry.escrowedPrivateKeyJwk === undefined) {
1343
+ return fail(`Failed to load private key for '${name}': ${loadResult.message}`);
1344
+ }
1345
+ // Fill-a-gap recovery: import the escrowed key non-extractable where the
1346
+ // backend supports it (extractable otherwise, since a JWK-round-tripping
1347
+ // backend cannot store a non-extractable key), then persist it.
1348
+ const backendExtractable = !storage.supportsNonExtractable;
1349
+ const importResult = await this._importEscrowedPrivateKey(entry.escrowedPrivateKeyJwk, entry.algorithm, backendExtractable);
1350
+ if (importResult.isFailure()) {
1351
+ return fail(`Failed to rehydrate private key for '${name}' from escrow: ${importResult.message}`);
1352
+ }
1353
+ const storeResult = await storage.store(entry.id, importResult.value);
1354
+ if (storeResult.isFailure()) {
1355
+ return fail(`Failed to persist rehydrated private key for '${name}': ${storeResult.message}`);
1356
+ }
1357
+ return succeed(importResult.value);
1358
+ }
1359
+ /**
1360
+ * Computes the key usages to request when importing an escrowed private JWK.
1361
+ * Mirrors `EncryptedFilePrivateKeyStorage`: intersect the algorithm's private
1362
+ * usages (its keypair usages minus the public-only ones) with the JWK's
1363
+ * recorded `key_ops` so we request exactly the operations the stored key
1364
+ * supports; fall back to the algorithm's private usages when `key_ops` is
1365
+ * absent.
1366
+ */
1367
+ static _privateKeyUsagesFor(jwk, params) {
1368
+ const privateUsages = params.keyPairUsages.filter((usage) => !PUBLIC_ONLY_USAGES.includes(usage));
1369
+ const keyOps = jwk.key_ops;
1370
+ if (keyOps === undefined) {
1371
+ return [...privateUsages];
1372
+ }
1373
+ return privateUsages.filter((usage) => keyOps.includes(usage));
1374
+ }
1236
1375
  /**
1237
1376
  * Constant-time byte comparison. Returns false immediately for length
1238
1377
  * mismatch (length is not secret); for equal-length inputs, walks the full