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/LICENSE +21 -0
- package/README.md +169 -0
- package/package.json +58 -0
- package/src/client.js +874 -0
- package/src/index.js +50 -0
- package/src/protocol.js +548 -0
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';
|
package/src/protocol.js
ADDED
|
@@ -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
|
+
}
|