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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,57 @@ export interface IEncryptionResult {
51
51
  * @public
52
52
  */
53
53
  export type KeyPairAlgorithm = 'ecdsa-p256' | 'rsa-oaep-2048';
54
+ /**
55
+ * Caller-supplied HKDF parameters that domain-separate one
56
+ * {@link CryptoUtils.ICryptoProvider.wrapBytes | wrapBytes} call from another.
57
+ * Two wraps that share recipient but differ on `salt` or `info` derive distinct
58
+ * wrap keys, so callers should pick values that bind the wrap to its
59
+ * application context (e.g. a content hash for `salt` and a secret name for
60
+ * `info`).
61
+ *
62
+ * Both fields are required; pass an empty `Uint8Array` if the caller has no
63
+ * value to bind on a given axis. Silent defaulting would hide protocol
64
+ * mistakes, so the API does not pick defaults.
65
+ * @public
66
+ */
67
+ export interface IWrapBytesOptions {
68
+ /**
69
+ * HKDF salt. Domain-separates this wrap from others in different contexts.
70
+ * Caller picks; common choices include a content hash, document id, channel
71
+ * id, etc.
72
+ */
73
+ readonly salt: Uint8Array;
74
+ /**
75
+ * HKDF info. Further binds the derived key to a specific use within the
76
+ * calling application. Caller picks; common choices include a secret name,
77
+ * message type, or version tag.
78
+ */
79
+ readonly info: Uint8Array;
80
+ }
81
+ /**
82
+ * Output of {@link CryptoUtils.ICryptoProvider.wrapBytes | wrapBytes}. The
83
+ * shape is JSON-serializable so it can travel directly over the wire or be
84
+ * persisted as-is.
85
+ * @public
86
+ */
87
+ export interface IWrappedBytes {
88
+ /**
89
+ * Sender's ephemeral ECDH P-256 public key as a JSON Web Key. The matching
90
+ * ephemeral private key is dropped after the shared-secret derive.
91
+ */
92
+ readonly ephemeralPublicKey: JsonWebKey;
93
+ /**
94
+ * AES-GCM nonce, base64-encoded. 12 bytes (96 bits) — the standard AES-GCM
95
+ * nonce length.
96
+ */
97
+ readonly nonce: string;
98
+ /**
99
+ * AES-GCM ciphertext concatenated with the 16-byte authentication tag,
100
+ * base64-encoded. Tampering with either the nonce or the ciphertext causes
101
+ * unwrap to fail GCM authentication.
102
+ */
103
+ readonly ciphertext: string;
104
+ }
54
105
  /**
55
106
  * All valid key pair algorithms.
56
107
  * @public
@@ -207,6 +258,47 @@ export interface ICryptoProvider {
207
258
  * @returns Success with the imported public `CryptoKey`, or Failure with error context.
208
259
  */
209
260
  importPublicKeyJwk(jwk: JsonWebKey, algorithm: KeyPairAlgorithm): Promise<Result<CryptoKey>>;
261
+ /**
262
+ * Wraps `plaintext` for delivery to the holder of the private key paired
263
+ * with `recipientPublicKey`. Uses ECIES with ECDH P-256, HKDF-SHA256, and
264
+ * AES-GCM-256.
265
+ *
266
+ * Generates a fresh ephemeral keypair per call; the ephemeral private key
267
+ * is discarded after the shared-secret derive. Only the recipient (with the
268
+ * matching private key) and the same HKDF parameters can recover
269
+ * `plaintext`.
270
+ *
271
+ * Empty `plaintext` is permitted; the resulting wrap contains only the
272
+ * 16-byte GCM authentication tag and round-trips back to an empty
273
+ * `Uint8Array`.
274
+ * @param plaintext - The bytes to wrap. Any length supported by AES-GCM
275
+ * (in practice, well below 2^39 - 256 bits).
276
+ * @param recipientPublicKey - The recipient's ECDH P-256 public `CryptoKey`.
277
+ * Must have algorithm name `'ECDH'` and named curve `'P-256'`; mismatched
278
+ * algorithm or curve yields a `Failure` with error context.
279
+ * @param options - HKDF parameters; see {@link CryptoUtils.IWrapBytesOptions | IWrapBytesOptions}.
280
+ * @returns `Success` with the wrapped payload, or `Failure` with error context.
281
+ */
282
+ wrapBytes(plaintext: Uint8Array, recipientPublicKey: CryptoKey, options: IWrapBytesOptions): Promise<Result<IWrappedBytes>>;
283
+ /**
284
+ * Inverse of {@link CryptoUtils.ICryptoProvider.wrapBytes | wrapBytes}.
285
+ * Recovers the original `plaintext` from a wrapped payload using the
286
+ * recipient's private key.
287
+ *
288
+ * Returns a `Failure` (never throws) on any of:
289
+ * - Tampered nonce or ciphertext (AES-GCM authentication fails)
290
+ * - Wrong private key (different shared secret derives a different wrap key)
291
+ * - Wrong HKDF parameters (different wrap key)
292
+ * - Malformed `ephemeralPublicKey` JWK
293
+ * - Malformed base64 in `nonce` or `ciphertext`
294
+ * @param wrapped - The wrapped payload produced by `wrapBytes`.
295
+ * @param recipientPrivateKey - The recipient's ECDH P-256 private
296
+ * `CryptoKey`. Must have algorithm name `'ECDH'` and named curve `'P-256'`,
297
+ * and key usages including `'deriveKey'` or `'deriveBits'`.
298
+ * @param options - The same HKDF parameters used at wrap time.
299
+ * @returns `Success` with the original `plaintext`, or `Failure` with error context.
300
+ */
301
+ unwrapBytes(wrapped: IWrappedBytes, recipientPrivateKey: CryptoKey, options: IWrapBytesOptions): Promise<Result<Uint8Array>>;
210
302
  }
211
303
  /**
212
304
  * High-level interface for encrypting JSON content by secret name.
@@ -1,5 +1,5 @@
1
1
  import { Result } from '@fgv/ts-utils';
2
- import { ICryptoProvider, IEncryptionResult, KeyPairAlgorithm } from './model';
2
+ import { ICryptoProvider, IEncryptionResult, IWrapBytesOptions, IWrappedBytes, KeyPairAlgorithm } from './model';
3
3
  /**
4
4
  * Node.js implementation of {@link CryptoUtils.ICryptoProvider} using the built-in crypto module.
5
5
  * Uses AES-256-GCM for authenticated encryption.
@@ -84,6 +84,25 @@ export declare class NodeCryptoProvider implements ICryptoProvider {
84
84
  * @returns `Success` with the imported public `CryptoKey`, or `Failure` with an error.
85
85
  */
86
86
  importPublicKeyJwk(jwk: JsonWebKey, algorithm: KeyPairAlgorithm): Promise<Result<CryptoKey>>;
87
+ /**
88
+ * Wraps `plaintext` for the holder of `recipientPublicKey` using
89
+ * ECIES (ECDH P-256 + HKDF-SHA256 + AES-GCM-256). See
90
+ * {@link CryptoUtils.ICryptoProvider.wrapBytes | ICryptoProvider.wrapBytes}.
91
+ * @param plaintext - The bytes to wrap.
92
+ * @param recipientPublicKey - The recipient's ECDH P-256 public `CryptoKey`.
93
+ * @param options - HKDF salt and info; see {@link CryptoUtils.IWrapBytesOptions | IWrapBytesOptions}.
94
+ * @returns `Success` with the wrapped payload, or `Failure` with an error.
95
+ */
96
+ wrapBytes(plaintext: Uint8Array, recipientPublicKey: CryptoKey, options: IWrapBytesOptions): Promise<Result<IWrappedBytes>>;
97
+ /**
98
+ * Unwraps a payload produced by `wrapBytes` using the recipient's private
99
+ * key. See {@link CryptoUtils.ICryptoProvider.unwrapBytes | ICryptoProvider.unwrapBytes}.
100
+ * @param wrapped - The wrapped payload.
101
+ * @param recipientPrivateKey - The recipient's ECDH P-256 private `CryptoKey`.
102
+ * @param options - HKDF salt and info matching the wrap call.
103
+ * @returns `Success` with the original `plaintext`, or `Failure` with an error.
104
+ */
105
+ unwrapBytes(wrapped: IWrappedBytes, recipientPrivateKey: CryptoKey, options: IWrapBytesOptions): Promise<Result<Uint8Array>>;
87
106
  }
88
107
  /**
89
108
  * Singleton instance of {@link CryptoUtils.NodeCryptoProvider}.
@@ -241,8 +241,104 @@ class NodeCryptoProvider {
241
241
  const result = await (0, ts_utils_1.captureAsyncResult)(() => crypto.webcrypto.subtle.importKey('jwk', jwk, params.importPublicKey, true, params.publicKeyUsages));
242
242
  return result.withErrorFormat((e) => `Failed to import ${algorithm} public key from JWK: ${e}`);
243
243
  }
244
+ /**
245
+ * Wraps `plaintext` for the holder of `recipientPublicKey` using
246
+ * ECIES (ECDH P-256 + HKDF-SHA256 + AES-GCM-256). See
247
+ * {@link CryptoUtils.ICryptoProvider.wrapBytes | ICryptoProvider.wrapBytes}.
248
+ * @param plaintext - The bytes to wrap.
249
+ * @param recipientPublicKey - The recipient's ECDH P-256 public `CryptoKey`.
250
+ * @param options - HKDF salt and info; see {@link CryptoUtils.IWrapBytesOptions | IWrapBytesOptions}.
251
+ * @returns `Success` with the wrapped payload, or `Failure` with an error.
252
+ */
253
+ async wrapBytes(plaintext, recipientPublicKey, options) {
254
+ const recipientCheck = checkEcdhP256(recipientPublicKey, 'public', 'recipient public key');
255
+ if (recipientCheck.isFailure()) {
256
+ return (0, ts_utils_1.fail)(`wrapBytes failed: ${recipientCheck.message}`);
257
+ }
258
+ const subtle = crypto.webcrypto.subtle;
259
+ const result = await (0, ts_utils_1.captureAsyncResult)(async () => {
260
+ const ephemeral = (await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
261
+ 'deriveKey'
262
+ ]));
263
+ const hkdfBase = await subtle.deriveKey({ name: 'ECDH', public: recipientPublicKey }, ephemeral.privateKey, { name: 'HKDF' }, false, ['deriveKey']);
264
+ const wrapKey = await subtle.deriveKey({ name: 'HKDF', salt: options.salt, info: options.info, hash: 'SHA-256' }, hkdfBase, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
265
+ const nonce = crypto.randomBytes(Constants.GCM_IV_SIZE);
266
+ const ctBuf = await subtle.encrypt({ name: 'AES-GCM', iv: nonce }, wrapKey, plaintext);
267
+ const ephemeralPublicKey = await subtle.exportKey('jwk', ephemeral.publicKey);
268
+ return {
269
+ ephemeralPublicKey,
270
+ nonce: this.toBase64(nonce),
271
+ ciphertext: this.toBase64(new Uint8Array(ctBuf))
272
+ };
273
+ });
274
+ return result.withErrorFormat((e) => `wrapBytes failed: ${e}`);
275
+ }
276
+ /**
277
+ * Unwraps a payload produced by `wrapBytes` using the recipient's private
278
+ * key. See {@link CryptoUtils.ICryptoProvider.unwrapBytes | ICryptoProvider.unwrapBytes}.
279
+ * @param wrapped - The wrapped payload.
280
+ * @param recipientPrivateKey - The recipient's ECDH P-256 private `CryptoKey`.
281
+ * @param options - HKDF salt and info matching the wrap call.
282
+ * @returns `Success` with the original `plaintext`, or `Failure` with an error.
283
+ */
284
+ async unwrapBytes(wrapped, recipientPrivateKey, options) {
285
+ const recipientCheck = checkEcdhP256(recipientPrivateKey, 'private', 'recipient private key');
286
+ if (recipientCheck.isFailure()) {
287
+ return (0, ts_utils_1.fail)(`unwrapBytes failed: ${recipientCheck.message}`);
288
+ }
289
+ const nonceResult = this.fromBase64(wrapped.nonce);
290
+ if (nonceResult.isFailure()) {
291
+ return (0, ts_utils_1.fail)(`unwrapBytes failed: nonce: ${nonceResult.message}`);
292
+ }
293
+ if (nonceResult.value.length !== Constants.GCM_IV_SIZE) {
294
+ return (0, ts_utils_1.fail)(`unwrapBytes failed: nonce must be ${Constants.GCM_IV_SIZE} bytes (got ${nonceResult.value.length})`);
295
+ }
296
+ const ciphertextResult = this.fromBase64(wrapped.ciphertext);
297
+ if (ciphertextResult.isFailure()) {
298
+ return (0, ts_utils_1.fail)(`unwrapBytes failed: ciphertext: ${ciphertextResult.message}`);
299
+ }
300
+ if (ciphertextResult.value.length < Constants.GCM_AUTH_TAG_SIZE) {
301
+ return (0, ts_utils_1.fail)(`unwrapBytes failed: ciphertext must be at least ${Constants.GCM_AUTH_TAG_SIZE} bytes (got ${ciphertextResult.value.length})`);
302
+ }
303
+ const subtle = crypto.webcrypto.subtle;
304
+ const result = await (0, ts_utils_1.captureAsyncResult)(async () => {
305
+ const ephemeralPub = await subtle.importKey('jwk', wrapped.ephemeralPublicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
306
+ const hkdfBase = await subtle.deriveKey({ name: 'ECDH', public: ephemeralPub }, recipientPrivateKey, { name: 'HKDF' }, false, ['deriveKey']);
307
+ const wrapKey = await subtle.deriveKey({ name: 'HKDF', salt: options.salt, info: options.info, hash: 'SHA-256' }, hkdfBase, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
308
+ const ptBuf = await subtle.decrypt({ name: 'AES-GCM', iv: nonceResult.value }, wrapKey, ciphertextResult.value);
309
+ return new Uint8Array(ptBuf);
310
+ });
311
+ return result.withErrorFormat((e) => `unwrapBytes failed: ${e}`);
312
+ }
244
313
  }
245
314
  exports.NodeCryptoProvider = NodeCryptoProvider;
315
+ /**
316
+ * Verifies that `key` is an ECDH P-256 `CryptoKey` of the expected `keyType`
317
+ * (public or private). Used by the wrap/unwrap methods to surface a clean
318
+ * `Failure` instead of letting the WebCrypto deriveKey call throw a less
319
+ * informative error later in the pipeline. Key usages are intentionally not
320
+ * checked here: WebCrypto already produces a specific error if `deriveKey` is
321
+ * not in `usages`, and `deriveBits` is an equally valid alternative usage that
322
+ * an explicit check would have to track.
323
+ * @param key - The CryptoKey to validate.
324
+ * @param keyType - The required `key.type` ('public' for wrap, 'private' for unwrap).
325
+ * @param label - Human-readable role label included in the failure message.
326
+ * @returns `Success` with the key (unchanged) when the algorithm, curve, and
327
+ * type all match; otherwise `Failure` with `<label> must be ECDH P-256 (...)`.
328
+ */
329
+ function checkEcdhP256(key, keyType, label) {
330
+ if (key.algorithm.name !== 'ECDH') {
331
+ return (0, ts_utils_1.fail)(`${label} must be ECDH P-256 (got algorithm '${key.algorithm.name}')`);
332
+ }
333
+ const namedCurve = key.algorithm.namedCurve;
334
+ if (namedCurve !== 'P-256') {
335
+ return (0, ts_utils_1.fail)(`${label} must be ECDH P-256 (got curve '${namedCurve}')`);
336
+ }
337
+ if (key.type !== keyType) {
338
+ return (0, ts_utils_1.fail)(`${label} must be a ${keyType} CryptoKey (got '${key.type}')`);
339
+ }
340
+ return (0, ts_utils_1.succeed)(key);
341
+ }
246
342
  /**
247
343
  * Singleton instance of {@link CryptoUtils.NodeCryptoProvider}.
248
344
  * @public
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgv/ts-extras",
3
- "version": "5.1.0-18",
3
+ "version": "5.1.0-19",
4
4
  "description": "Assorted Typescript Utilities",
5
5
  "main": "lib/index.js",
6
6
  "types": "dist/ts-extras.d.ts",
@@ -86,10 +86,10 @@
86
86
  "@types/js-yaml": "~4.0.9",
87
87
  "typedoc": "~0.28.16",
88
88
  "typedoc-plugin-markdown": "~4.9.0",
89
- "@fgv/heft-dual-rig": "5.1.0-18",
90
- "@fgv/typedoc-compact-theme": "5.1.0-18",
91
- "@fgv/ts-utils-jest": "5.1.0-18",
92
- "@fgv/ts-utils": "5.1.0-18"
89
+ "@fgv/heft-dual-rig": "5.1.0-19",
90
+ "@fgv/typedoc-compact-theme": "5.1.0-19",
91
+ "@fgv/ts-utils": "5.1.0-19",
92
+ "@fgv/ts-utils-jest": "5.1.0-19"
93
93
  },
94
94
  "dependencies": {
95
95
  "luxon": "^3.7.2",
@@ -97,10 +97,10 @@
97
97
  "papaparse": "^5.4.1",
98
98
  "fflate": "~0.8.2",
99
99
  "js-yaml": "~4.1.1",
100
- "@fgv/ts-json-base": "5.1.0-18"
100
+ "@fgv/ts-json-base": "5.1.0-19"
101
101
  },
102
102
  "peerDependencies": {
103
- "@fgv/ts-utils": "5.1.0-18"
103
+ "@fgv/ts-utils": "5.1.0-19"
104
104
  },
105
105
  "repository": {
106
106
  "type": "git",