wscall-client 0.3.0

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/src/index.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * wscall-client - High-performance JavaScript client SDK for the WSCALL framework.
3
+ *
4
+ * @example
5
+ * ```js
6
+ * import { WscallClient, WscallClientConfig, createTextAttachment, attachmentRef } from 'wscall-client';
7
+ *
8
+ * const client = await WscallClient.connect('ws://127.0.0.1:9001/socket', WscallClientConfig.ecdh());
9
+ *
10
+ * // Make an API call
11
+ * const result = await client.call('system.echo', { message: 'hello' });
12
+ *
13
+ * // Listen for server events
14
+ * client.onEvent('chat.message', (event) => {
15
+ * console.log('New message:', event.data);
16
+ * return { received: true };
17
+ * });
18
+ *
19
+ * // Send an event
20
+ * const ack = await client.sendEvent('chat.message', { message: 'hi!' });
21
+ *
22
+ * client.close();
23
+ * ```
24
+ */
25
+
26
+ export { WscallClient, WscallClientConfig, ClientError } from './client.js';
27
+ export {
28
+ FrameCodec,
29
+ ProtocolError,
30
+ MessageType,
31
+ EncryptionKind,
32
+ DEFAULT_MAX_FRAME_BYTES,
33
+ K_API_REQUEST,
34
+ K_EVENT_EMIT,
35
+ K_API_RESPONSE,
36
+ K_EVENT_ACK,
37
+ ECDH_DOMAIN_TAG,
38
+ ECDH_KEY_LEN,
39
+ buildApiRequest,
40
+ buildEventEmit,
41
+ buildEventAck,
42
+ createAttachment,
43
+ createTextAttachment,
44
+ createBytesAttachment,
45
+ attachmentRef,
46
+ initCiphers,
47
+ generateEcdhKeypair,
48
+ parsePeerPublic,
49
+ deriveEcdhSessionKey,
50
+ } from './protocol.js';
@@ -0,0 +1,548 @@
1
+ /**
2
+ * WSCALL binary protocol codec (protocol v3).
3
+ *
4
+ * Frame layout (big-endian):
5
+ * ┌──────────────────────────────────────────────────────────────────────┐
6
+ * │ 4 bytes frame_len (u32 BE) │ 1 byte msg_type │ payload │
7
+ * └──────────────────────────────────────────────────────────────────────┘
8
+ * frame_len = 1 + payload.length
9
+ *
10
+ * The encryption mode is a connection-level property configured on the codec
11
+ * (via withChaCha20Key / withAes256Key); it is NOT carried per-frame.
12
+ *
13
+ * Protocol v3 composite payload (after decryption):
14
+ * ┌──────────────────────────────────────────────────────────────────────┐
15
+ * │ 4 bytes meta_len (u32 BE) │ JSON_bytes │ 1 byte att_count │ [att sections] │
16
+ * └──────────────────────────────────────────────────────────────────────┘
17
+ *
18
+ * Each attachment binary section:
19
+ * │ id_len:u8 │ id │ name_len:u8 │ name │ ct_len:u8 │ content_type │ data_len:u32(be) │ raw_data │
20
+ *
21
+ * JSON body uses compact single-letter keys with a numeric `k` discriminator:
22
+ * k=0 ApiRequest: { k, i, r, p, m }
23
+ * k=1 EventEmit: { k, i, n, d, m, e }
24
+ * k=2 ApiResponse: { k, i, o, s, d, er?, m }
25
+ * k=3 EventAck: { k, i, o, rc, er? }
26
+ */
27
+
28
+ // ─── Constants ────────────────────────────────────────────────────────────────
29
+
30
+ export const MessageType = Object.freeze({ Api: 0x00, Event: 0x01 });
31
+ export const EncryptionKind = Object.freeze({ None: 0x00, ChaCha20: 0x01, Aes256: 0x02 });
32
+
33
+ export const K_API_REQUEST = 0;
34
+ export const K_EVENT_EMIT = 1;
35
+ export const K_API_RESPONSE = 2;
36
+ export const K_EVENT_ACK = 3;
37
+
38
+ // ─── ECDH Constants ──────────────────────────────────────────────────────────
39
+
40
+ /** Domain-separation tag for the ECDH session key KDF (must match the Rust side). */
41
+ export const ECDH_DOMAIN_TAG = 'wscall-ecdh-v1';
42
+ /** Byte length of an X25519 public key. */
43
+ export const ECDH_KEY_LEN = 32;
44
+
45
+ const NONCE_LEN = 12;
46
+
47
+ /** Default maximum frame size: 100 MiB (configurable). */
48
+ export const DEFAULT_MAX_FRAME_BYTES = 100 * 1024 * 1024;
49
+
50
+ // Shared, stateless UTF-8 codecs. TextEncoder/TextDecoder are reusable, so
51
+ // hoisting them to module scope avoids re-instantiating them on every frame
52
+ // and attachment (which previously added GC pressure on hot paths).
53
+ const TEXT_ENCODER = new TextEncoder();
54
+ const TEXT_DECODER = new TextDecoder();
55
+
56
+ // ─── Errors ───────────────────────────────────────────────────────────────────
57
+
58
+ export class ProtocolError extends Error {
59
+ constructor(message, code = 'PROTOCOL_ERROR') {
60
+ super(message);
61
+ this.name = 'ProtocolError';
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ // ─── Frame Codec ──────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * High-performance binary frame codec for the WSCALL protocol.
70
+ * Supports plaintext, ChaCha20-Poly1305, and AES-256-GCM encryption.
71
+ */
72
+ export class FrameCodec {
73
+ #chacha20Key = null; // Uint8Array(32)
74
+ #aes256Key = null; // CryptoKey (imported lazily)
75
+ #aes256RawKey = null; // Uint8Array(32) raw bytes for lazy import
76
+ #wireEncryption = EncryptionKind.None; // connection-level encryption mode
77
+ #maxFrameBytes = DEFAULT_MAX_FRAME_BYTES;
78
+
79
+ constructor() {}
80
+
81
+ /** Configure ChaCha20-Poly1305 key (32 bytes). Sets wire encryption to ChaCha20. */
82
+ withChaCha20Key(key) {
83
+ if (!(key instanceof Uint8Array) || key.length !== 32) {
84
+ throw new ProtocolError('ChaCha20 key must be a 32-byte Uint8Array');
85
+ }
86
+ this.#chacha20Key = key;
87
+ this.#wireEncryption = EncryptionKind.ChaCha20;
88
+ return this;
89
+ }
90
+
91
+ /** Configure AES-256-GCM key (32 bytes). Sets wire encryption to Aes256. */
92
+ withAes256Key(key) {
93
+ if (!(key instanceof Uint8Array) || key.length !== 32) {
94
+ throw new ProtocolError('AES-256 key must be a 32-byte Uint8Array');
95
+ }
96
+ this.#aes256RawKey = key;
97
+ this.#aes256Key = null; // will be imported lazily
98
+ this.#wireEncryption = EncryptionKind.Aes256;
99
+ return this;
100
+ }
101
+
102
+ /** Returns the connection-level encryption mode. */
103
+ get wireEncryption() { return this.#wireEncryption; }
104
+
105
+ /** Set maximum frame size. */
106
+ withMaxFrameBytes(max) {
107
+ this.#maxFrameBytes = max;
108
+ return this;
109
+ }
110
+
111
+ /** Lazily import AES key into Web Crypto. */
112
+ async #getAesKey() {
113
+ if (!this.#aes256Key && this.#aes256RawKey) {
114
+ this.#aes256Key = await crypto.subtle.importKey(
115
+ 'raw', this.#aes256RawKey, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
116
+ );
117
+ }
118
+ return this.#aes256Key;
119
+ }
120
+
121
+ /**
122
+ * Encode a packet body object into a binary WSCALL frame (protocol v3).
123
+ * Attachments in body.a are extracted and encoded as raw binary sections.
124
+ * Encryption mode is taken from the codec's wireEncryption (connection-level).
125
+ * @param {object} body - Packet body (will be serialized with short keys)
126
+ * @returns {Promise<Uint8Array>} Encoded binary frame
127
+ */
128
+ async encode(body) {
129
+ const messageType = messageTypeForBody(body);
130
+ const encryption = this.#wireEncryption;
131
+ const maxPayload = this.#maxFrameBytes - 5;
132
+
133
+ // Extract attachments and remove from JSON serialization.
134
+ const attachments = body.a || [];
135
+ const jsonBody = { ...body };
136
+ delete jsonBody.a;
137
+
138
+ const jsonBytes = TEXT_ENCODER.encode(JSON.stringify(jsonBody));
139
+ const attSections = attachments.map(att => encodeAttachmentWire(att));
140
+ const attTotalSize = attSections.reduce((sum, s) => sum + s.length, 0);
141
+
142
+ // Plaintext fast path: write directly into the final frame buffer.
143
+ if (encryption === EncryptionKind.None) {
144
+ const frameLen = 1 + 4 + jsonBytes.length + 1 + attTotalSize;
145
+ if (frameLen - 1 > maxPayload) {
146
+ throw new ProtocolError(`Payload too large: ${frameLen - 1} > ${maxPayload}`);
147
+ }
148
+ const frame = new Uint8Array(4 + frameLen);
149
+ const view = new DataView(frame.buffer);
150
+ view.setUint32(0, frameLen, false);
151
+ frame[4] = messageType;
152
+ view.setUint32(5, jsonBytes.length, false);
153
+ frame.set(jsonBytes, 9);
154
+ frame[9 + jsonBytes.length] = attachments.length;
155
+ let offset = 10 + jsonBytes.length;
156
+ for (const section of attSections) {
157
+ frame.set(section, offset);
158
+ offset += section.length;
159
+ }
160
+ return frame;
161
+ }
162
+
163
+ // Encrypted path: build the composite, encrypt it, then wrap the frame.
164
+ const composite = new Uint8Array(4 + jsonBytes.length + 1 + attTotalSize);
165
+ const cView = new DataView(composite.buffer);
166
+ cView.setUint32(0, jsonBytes.length, false);
167
+ composite.set(jsonBytes, 4);
168
+ composite[4 + jsonBytes.length] = attachments.length;
169
+ let offset = 4 + jsonBytes.length + 1;
170
+ for (const section of attSections) {
171
+ composite.set(section, offset);
172
+ offset += section.length;
173
+ }
174
+
175
+ if (composite.length > maxPayload) {
176
+ throw new ProtocolError(`Payload too large: ${composite.length} > ${maxPayload}`);
177
+ }
178
+
179
+ let payload;
180
+ switch (encryption) {
181
+ case EncryptionKind.ChaCha20:
182
+ payload = this.#encryptChaCha20(composite);
183
+ break;
184
+ case EncryptionKind.Aes256:
185
+ payload = await this.#encryptAes256(composite);
186
+ break;
187
+ default:
188
+ throw new ProtocolError(`Unknown encryption kind: ${encryption}`);
189
+ }
190
+
191
+ if (payload.length > maxPayload) {
192
+ throw new ProtocolError(`Encrypted payload too large: ${payload.length} > ${maxPayload}`);
193
+ }
194
+
195
+ const frameLen = 1 + payload.length;
196
+ const frame = new Uint8Array(4 + frameLen);
197
+ const view = new DataView(frame.buffer);
198
+ view.setUint32(0, frameLen, false); // big-endian
199
+ frame[4] = messageType;
200
+ frame.set(payload, 5);
201
+ return frame;
202
+ }
203
+
204
+ /**
205
+ * Decode a binary WSCALL frame into a packet body object (protocol v3).
206
+ * Attachments are parsed from binary sections and injected as body.a.
207
+ * Encryption mode is taken from the codec's wireEncryption (connection-level).
208
+ * @param {Uint8Array|ArrayBuffer} data - Raw frame bytes
209
+ * @returns {Promise<{messageType: number, body: object}>}
210
+ */
211
+ async decode(data) {
212
+ const frame = data instanceof Uint8Array ? data : new Uint8Array(data);
213
+ if (frame.length < 5) {
214
+ throw new ProtocolError('Frame too short');
215
+ }
216
+
217
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
218
+ const declared = view.getUint32(0, false);
219
+ const actual = frame.length - 4;
220
+ if (declared !== actual) {
221
+ throw new ProtocolError(`Frame length mismatch: declared=${declared}, actual=${actual}`);
222
+ }
223
+
224
+ if (frame.length > this.#maxFrameBytes) {
225
+ throw new ProtocolError(`Frame too large: ${frame.length} > ${this.#maxFrameBytes}`);
226
+ }
227
+
228
+ const messageType = frame[4];
229
+ const encryptedPayload = frame.subarray(5);
230
+
231
+ let composite;
232
+ switch (this.#wireEncryption) {
233
+ case EncryptionKind.None:
234
+ composite = encryptedPayload;
235
+ break;
236
+ case EncryptionKind.ChaCha20:
237
+ composite = this.#decryptChaCha20(encryptedPayload);
238
+ break;
239
+ case EncryptionKind.Aes256:
240
+ composite = await this.#decryptAes256(encryptedPayload);
241
+ break;
242
+ default:
243
+ throw new ProtocolError(`Unknown encryption kind: ${this.#wireEncryption}`);
244
+ }
245
+
246
+ // Parse composite: meta_len:u32 | JSON | att_count:u8 | [att sections]
247
+ if (composite.length < 5) {
248
+ throw new ProtocolError('Composite payload too short');
249
+ }
250
+ const cView = new DataView(composite.buffer, composite.byteOffset, composite.byteLength);
251
+ const metaLen = cView.getUint32(0, false);
252
+ if (composite.length < 4 + metaLen + 1) {
253
+ throw new ProtocolError('Composite payload truncated');
254
+ }
255
+ const jsonBytes = composite.subarray(4, 4 + metaLen);
256
+ const attCount = composite[4 + metaLen];
257
+ let attOffset = 4 + metaLen + 1;
258
+
259
+ const body = JSON.parse(TEXT_DECODER.decode(jsonBytes));
260
+
261
+ // Parse binary attachment sections.
262
+ const attachments = [];
263
+ for (let i = 0; i < attCount; i++) {
264
+ const [att, newOffset] = decodeAttachmentWire(composite, attOffset);
265
+ attachments.push(att);
266
+ attOffset = newOffset;
267
+ }
268
+ if (attachments.length > 0) {
269
+ body.a = attachments;
270
+ }
271
+
272
+ return { messageType, body };
273
+ }
274
+
275
+ // ─── ChaCha20-Poly1305 (uses @noble/ciphers) ──────────────────────────────
276
+
277
+ #encryptChaCha20(plaintext) {
278
+ // NOTE: chacha20poly1305(key, nonce) is constructed per frame by design —
279
+ // the AEAD requires a fresh nonce each call and @noble/ciphers exposes no
280
+ // safe cross-frame key-schedule cache, so there is nothing to hoist here.
281
+ if (!this.#chacha20Key) throw new ProtocolError('Missing ChaCha20 key');
282
+ const { chacha20poly1305 } = getCiphers();
283
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
284
+ const cipher = chacha20poly1305(this.#chacha20Key, nonce);
285
+ const ciphertext = cipher.encrypt(plaintext);
286
+ // nonce || ciphertext+tag
287
+ const result = new Uint8Array(NONCE_LEN + ciphertext.length);
288
+ result.set(nonce, 0);
289
+ result.set(ciphertext, NONCE_LEN);
290
+ return result;
291
+ }
292
+
293
+ #decryptChaCha20(payload) {
294
+ if (!this.#chacha20Key) throw new ProtocolError('Missing ChaCha20 key');
295
+ if (payload.length < NONCE_LEN) throw new ProtocolError('Encrypted payload too short for ChaCha20');
296
+ const { chacha20poly1305 } = getCiphers();
297
+ const nonce = payload.subarray(0, NONCE_LEN);
298
+ const ciphertext = payload.subarray(NONCE_LEN);
299
+ const cipher = chacha20poly1305(this.#chacha20Key, nonce);
300
+ return cipher.decrypt(ciphertext);
301
+ }
302
+
303
+ // ─── AES-256-GCM (uses Web Crypto API) ────────────────────────────────────
304
+
305
+ async #encryptAes256(plaintext) {
306
+ const key = await this.#getAesKey();
307
+ if (!key) throw new ProtocolError('Missing AES-256 key');
308
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
309
+ const ciphertext = new Uint8Array(
310
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext)
311
+ );
312
+ const result = new Uint8Array(NONCE_LEN + ciphertext.length);
313
+ result.set(nonce, 0);
314
+ result.set(ciphertext, NONCE_LEN);
315
+ return result;
316
+ }
317
+
318
+ async #decryptAes256(payload) {
319
+ const key = await this.#getAesKey();
320
+ if (!key) throw new ProtocolError('Missing AES-256 key');
321
+ if (payload.length < NONCE_LEN) throw new ProtocolError('Encrypted payload too short for AES-256');
322
+ const nonce = payload.subarray(0, NONCE_LEN);
323
+ const ciphertext = payload.subarray(NONCE_LEN);
324
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ciphertext);
325
+ return new Uint8Array(plaintext);
326
+ }
327
+ }
328
+
329
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
330
+
331
+ function messageTypeForBody(body) {
332
+ const k = body.k;
333
+ if (k === K_API_REQUEST || k === K_API_RESPONSE) return MessageType.Api;
334
+ if (k === K_EVENT_EMIT || k === K_EVENT_ACK) return MessageType.Event;
335
+ throw new ProtocolError(`Unknown packet kind: ${k}`);
336
+ }
337
+
338
+ // ─── Attachment Wire Encoding ───────────────────────────────────────────────
339
+
340
+ /**
341
+ * Encode a single attachment into its binary wire section.
342
+ * Layout: id_len:u8 | id | name_len:u8 | name | ct_len:u8 | content_type | data_len:u32(be) | raw_data
343
+ */
344
+ function encodeAttachmentWire(att) {
345
+ const idBytes = TEXT_ENCODER.encode(att.id);
346
+ const nameBytes = TEXT_ENCODER.encode(att.name);
347
+ const ctBytes = TEXT_ENCODER.encode(att.content_type);
348
+ const data = att.data instanceof Uint8Array ? att.data : new Uint8Array(att.data);
349
+ const total = 1 + idBytes.length + 1 + nameBytes.length + 1 + ctBytes.length + 4 + data.length;
350
+ const buf = new Uint8Array(total);
351
+ const view = new DataView(buf.buffer);
352
+ let pos = 0;
353
+ buf[pos++] = idBytes.length;
354
+ buf.set(idBytes, pos); pos += idBytes.length;
355
+ buf[pos++] = nameBytes.length;
356
+ buf.set(nameBytes, pos); pos += nameBytes.length;
357
+ buf[pos++] = ctBytes.length;
358
+ buf.set(ctBytes, pos); pos += ctBytes.length;
359
+ view.setUint32(pos, data.length, false); pos += 4;
360
+ buf.set(data, pos);
361
+ return buf;
362
+ }
363
+
364
+ /**
365
+ * Decode a single attachment binary section from a composite buffer.
366
+ * @returns {[object, number]} [attachment, newOffset]
367
+ */
368
+ function decodeAttachmentWire(buf, offset) {
369
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
370
+ let pos = offset;
371
+
372
+ const idLen = buf[pos++];
373
+ const id = TEXT_DECODER.decode(buf.subarray(pos, pos + idLen)); pos += idLen;
374
+
375
+ const nameLen = buf[pos++];
376
+ const name = TEXT_DECODER.decode(buf.subarray(pos, pos + nameLen)); pos += nameLen;
377
+
378
+ const ctLen = buf[pos++];
379
+ const content_type = TEXT_DECODER.decode(buf.subarray(pos, pos + ctLen)); pos += ctLen;
380
+
381
+ const dataLen = view.getUint32(pos, false); pos += 4;
382
+ // Zero-copy view into the (plaintext) frame buffer. The view retains the
383
+ // underlying buffer until released; for encrypted frames `buf` is already a
384
+ // fresh decrypted allocation, so no extra large buffer is pinned.
385
+ const data = buf.subarray(pos, pos + dataLen); pos += dataLen;
386
+
387
+ return [{ id, name, content_type, data }, pos];
388
+ }
389
+
390
+ // Lazy-load @noble/ciphers to keep it optional
391
+ let _ciphers = null;
392
+ function getCiphers() {
393
+ if (!_ciphers) {
394
+ try {
395
+ // Dynamic import won't work synchronously; use createRequire pattern
396
+ // In practice this is loaded at module init via the top-level import
397
+ _ciphers = globalThis.__wscall_ciphers;
398
+ } catch { /* ignore */ }
399
+ }
400
+ if (!_ciphers) {
401
+ throw new ProtocolError(
402
+ 'ChaCha20-Poly1305 requires @noble/ciphers. Install it: npm i @noble/ciphers'
403
+ );
404
+ }
405
+ return _ciphers;
406
+ }
407
+
408
+ /**
409
+ * Initialize the cipher backend. Called automatically by WscallClient.
410
+ * @param {{ chacha20poly1305: Function }} ciphers
411
+ */
412
+ export function initCiphers(ciphers) {
413
+ globalThis.__wscall_ciphers = ciphers;
414
+ }
415
+
416
+ // ─── Packet Builders ──────────────────────────────────────────────────────────
417
+
418
+ /** Build an ApiRequest packet body. */
419
+ export function buildApiRequest(requestId, route, params = {}, attachments = [], metadata = {}) {
420
+ const body = { k: K_API_REQUEST, i: requestId, r: route, p: params };
421
+ if (metadata && Object.keys(metadata).length > 0) body.m = metadata;
422
+ if (attachments.length > 0) body.a = attachments;
423
+ return body;
424
+ }
425
+
426
+ /** Build an EventEmit packet body. */
427
+ export function buildEventEmit(eventId, name, data = {}, attachments = [], metadata = {}, expectAck = true) {
428
+ const body = { k: K_EVENT_EMIT, i: eventId, n: name, d: data, e: expectAck };
429
+ if (metadata && Object.keys(metadata).length > 0) body.m = metadata;
430
+ if (attachments.length > 0) body.a = attachments;
431
+ return body;
432
+ }
433
+
434
+ /** Build an EventAck packet body. */
435
+ export function buildEventAck(eventId, ok = true, receipt = {}, error = undefined) {
436
+ const body = { k: K_EVENT_ACK, i: eventId, o: ok, rc: receipt };
437
+ if (error) body.er = error;
438
+ return body;
439
+ }
440
+
441
+ /**
442
+ * Create a file attachment object (raw binary, protocol v3).
443
+ * @param {string} id - Attachment identifier
444
+ * @param {string} name - File name
445
+ * @param {string} contentType - MIME type
446
+ * @param {Uint8Array} data - Raw file bytes
447
+ */
448
+ export function createAttachment(id, name, contentType, data) {
449
+ return { id, name, content_type: contentType, data: data instanceof Uint8Array ? data : new Uint8Array(data) };
450
+ }
451
+
452
+ /** Create a text attachment (encodes string to UTF-8 bytes). */
453
+ export function createTextAttachment(id, name, contentType, text) {
454
+ return { id, name, content_type: contentType, data: new TextEncoder().encode(text) };
455
+ }
456
+
457
+ /** Create a binary attachment from Uint8Array (stored as-is, no Base64). */
458
+ export function createBytesAttachment(id, name, contentType, bytes) {
459
+ return { id, name, content_type: contentType, data: bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) };
460
+ }
461
+
462
+ /** Returns a JSON reference object pointing to an attachment by id. */
463
+ export function attachmentRef(id) {
464
+ return { $file: id };
465
+ }
466
+
467
+ // ─── ECDH Key Agreement ──────────────────────────────────────────────────────
468
+
469
+ /**
470
+ * Lazy-loads the X25519 module from @noble/curves.
471
+ * Keeps the import optional so projects that only use PSK don't pay the cost.
472
+ */
473
+ let _x25519 = null;
474
+ async function getX25519() {
475
+ if (!_x25519) {
476
+ let mod;
477
+ try {
478
+ // In @noble/curves v1.x, x25519 is re-exported from the main entry.
479
+ mod = await import('@noble/curves');
480
+ } catch {
481
+ try {
482
+ // Fallback: import from the ed25519 submodule directly.
483
+ mod = await import('@noble/curves/ed25519.js');
484
+ } catch {
485
+ throw new ProtocolError(
486
+ 'ECDH requires @noble/curves. Install it: npm i @noble/curves'
487
+ );
488
+ }
489
+ }
490
+ if (!mod.x25519) {
491
+ throw new ProtocolError(
492
+ 'ECDH requires @noble/curves. Install it: npm i @noble/curves'
493
+ );
494
+ }
495
+ _x25519 = mod.x25519;
496
+ }
497
+ return _x25519;
498
+ }
499
+
500
+ /**
501
+ * Generates a random X25519 keypair for the ECDH handshake.
502
+ * @returns {Promise<{ privateKey: Uint8Array, publicKey: Uint8Array }>}
503
+ */
504
+ export async function generateEcdhKeypair() {
505
+ const x25519 = await getX25519();
506
+ const privateKey = x25519.utils.randomPrivateKey();
507
+ const publicKey = x25519.getPublicKey(privateKey);
508
+ return { privateKey, publicKey };
509
+ }
510
+
511
+ /**
512
+ * Validates that `bytes` is exactly 32 bytes (a valid X25519 public key).
513
+ * @param {Uint8Array} bytes
514
+ * @returns {Uint8Array} the same bytes if valid
515
+ * @throws {ProtocolError} if the length is wrong
516
+ */
517
+ export function parsePeerPublic(bytes) {
518
+ const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
519
+ if (arr.length !== ECDH_KEY_LEN) {
520
+ throw new ProtocolError(
521
+ `Invalid ECDH public key: expected ${ECDH_KEY_LEN} bytes, got ${arr.length}`
522
+ );
523
+ }
524
+ return arr;
525
+ }
526
+
527
+ /**
528
+ * Derives a 32-byte ChaCha20-Poly1305 session key from an X25519 shared secret.
529
+ *
530
+ * Uses SHA-256(domain ‖ sharedSecret) for domain separation,
531
+ * matching the Rust implementation.
532
+ *
533
+ * @param {Uint8Array} privateKey - our X25519 private key
534
+ * @param {Uint8Array} peerPublicKey - peer's 32-byte public key
535
+ * @returns {Promise<Uint8Array>} 32-byte session key
536
+ */
537
+ export async function deriveEcdhSessionKey(privateKey, peerPublicKey) {
538
+ const x25519 = await getX25519();
539
+ const sharedSecret = x25519.getSharedSecret(privateKey, peerPublicKey);
540
+
541
+ const domainBytes = new TextEncoder().encode(ECDH_DOMAIN_TAG);
542
+ const input = new Uint8Array(domainBytes.length + sharedSecret.length);
543
+ input.set(domainBytes, 0);
544
+ input.set(sharedSecret, domainBytes.length);
545
+
546
+ const hashBuffer = await crypto.subtle.digest('SHA-256', input);
547
+ return new Uint8Array(hashBuffer);
548
+ }