@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.
- package/dist/packlets/ai-assist/apiClient.js +247 -24
- package/dist/packlets/ai-assist/index.js +1 -1
- package/dist/packlets/ai-assist/registry.js +49 -4
- package/dist/packlets/crypto-utils/nodeCryptoProvider.js +96 -0
- package/dist/ts-extras.d.ts +222 -8
- package/lib/packlets/ai-assist/apiClient.d.ts +11 -3
- package/lib/packlets/ai-assist/apiClient.js +245 -22
- package/lib/packlets/ai-assist/index.d.ts +2 -2
- package/lib/packlets/ai-assist/index.js +3 -1
- package/lib/packlets/ai-assist/model.d.ts +66 -5
- package/lib/packlets/ai-assist/registry.d.ts +25 -1
- package/lib/packlets/ai-assist/registry.js +51 -4
- package/lib/packlets/crypto-utils/model.d.ts +92 -0
- package/lib/packlets/crypto-utils/nodeCryptoProvider.d.ts +20 -1
- package/lib/packlets/crypto-utils/nodeCryptoProvider.js +96 -0
- package/package.json +7 -7
|
@@ -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-
|
|
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-
|
|
90
|
-
"@fgv/typedoc-compact-theme": "5.1.0-
|
|
91
|
-
"@fgv/ts-utils
|
|
92
|
-
"@fgv/ts-utils": "5.1.0-
|
|
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-
|
|
100
|
+
"@fgv/ts-json-base": "5.1.0-19"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
|
-
"@fgv/ts-utils": "5.1.0-
|
|
103
|
+
"@fgv/ts-utils": "5.1.0-19"
|
|
104
104
|
},
|
|
105
105
|
"repository": {
|
|
106
106
|
"type": "git",
|