@aegis-fluxion/core 0.7.2 → 0.9.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/dist/index.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var crypto = require('crypto');
4
+ var stream = require('stream');
4
5
  var WebSocket = require('ws');
5
6
 
6
7
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -13,20 +14,31 @@ var DEFAULT_CLOSE_REASON = "";
13
14
  var POLICY_VIOLATION_CLOSE_CODE = 1008;
14
15
  var POLICY_VIOLATION_CLOSE_REASON = "Connection rejected by middleware.";
15
16
  var INTERNAL_HANDSHAKE_EVENT = "__handshake";
17
+ var INTERNAL_SESSION_TICKET_EVENT = "__session:ticket";
16
18
  var INTERNAL_RPC_REQUEST_EVENT = "__rpc:req";
17
19
  var INTERNAL_RPC_RESPONSE_EVENT = "__rpc:res";
20
+ var INTERNAL_STREAM_FRAME_EVENT = "__stream:frame";
18
21
  var READY_EVENT = "ready";
19
22
  var HANDSHAKE_CURVE = "prime256v1";
23
+ var HANDSHAKE_PROTOCOL_VERSION = 1;
20
24
  var ENCRYPTION_ALGORITHM = "aes-256-gcm";
21
25
  var GCM_IV_LENGTH = 12;
22
26
  var GCM_AUTH_TAG_LENGTH = 16;
23
27
  var ENCRYPTION_KEY_LENGTH = 32;
24
28
  var ENCRYPTED_PACKET_VERSION = 1;
25
29
  var ENCRYPTED_PACKET_PREFIX_LENGTH = 1 + GCM_IV_LENGTH + GCM_AUTH_TAG_LENGTH;
30
+ var SESSION_TICKET_VERSION = 1;
26
31
  var BINARY_PAYLOAD_MARKER = "__afxBinaryPayload";
27
32
  var BINARY_PAYLOAD_VERSION = 1;
28
33
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
29
34
  var DEFAULT_HEARTBEAT_TIMEOUT_MS = 15e3;
35
+ var DEFAULT_SESSION_RESUMPTION_ENABLED = true;
36
+ var DEFAULT_SESSION_TICKET_TTL_MS = 10 * 6e4;
37
+ var DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE = 1e4;
38
+ var STREAM_FRAME_VERSION = 1;
39
+ var DEFAULT_STREAM_CHUNK_SIZE_BYTES = 64 * 1024;
40
+ var MAX_STREAM_CHUNK_SIZE_BYTES = 1024 * 1024;
41
+ var RESUMPTION_NONCE_LENGTH = 16;
30
42
  var DEFAULT_RECONNECT_INITIAL_DELAY_MS = 250;
31
43
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 1e4;
32
44
  var DEFAULT_RECONNECT_FACTOR = 2;
@@ -248,7 +260,7 @@ function decodeCloseReason(reason) {
248
260
  return reason.toString("utf8");
249
261
  }
250
262
  function isReservedEmitEvent(event) {
251
- return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === READY_EVENT;
263
+ return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === INTERNAL_STREAM_FRAME_EVENT || event === READY_EVENT;
252
264
  }
253
265
  function isPromiseLike(value) {
254
266
  return typeof value === "object" && value !== null && "then" in value;
@@ -260,6 +272,240 @@ function normalizeRpcTimeout(timeoutMs) {
260
272
  }
261
273
  return resolvedTimeoutMs;
262
274
  }
275
+ function normalizeStreamChunkSize(chunkSizeBytes) {
276
+ const resolvedChunkSize = chunkSizeBytes ?? DEFAULT_STREAM_CHUNK_SIZE_BYTES;
277
+ if (!Number.isInteger(resolvedChunkSize) || resolvedChunkSize <= 0) {
278
+ throw new Error("Stream chunkSizeBytes must be a positive integer.");
279
+ }
280
+ if (resolvedChunkSize > MAX_STREAM_CHUNK_SIZE_BYTES) {
281
+ throw new Error(
282
+ `Stream chunkSizeBytes cannot exceed ${MAX_STREAM_CHUNK_SIZE_BYTES} bytes.`
283
+ );
284
+ }
285
+ return resolvedChunkSize;
286
+ }
287
+ function resolveKnownStreamSourceSize(source, hint) {
288
+ if (hint !== void 0) {
289
+ if (!Number.isInteger(hint) || hint < 0) {
290
+ throw new Error("Stream totalBytes must be a non-negative integer.");
291
+ }
292
+ return hint;
293
+ }
294
+ if (Buffer.isBuffer(source)) {
295
+ return source.length;
296
+ }
297
+ if (source instanceof Uint8Array) {
298
+ return source.byteLength;
299
+ }
300
+ return void 0;
301
+ }
302
+ function normalizeChunkSourceValue(value) {
303
+ if (Buffer.isBuffer(value)) {
304
+ return value;
305
+ }
306
+ if (value instanceof Uint8Array) {
307
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
308
+ }
309
+ if (value instanceof ArrayBuffer) {
310
+ return Buffer.from(value);
311
+ }
312
+ if (typeof value === "string") {
313
+ return Buffer.from(value, "utf8");
314
+ }
315
+ throw new Error("Stream source yielded an unsupported chunk value.");
316
+ }
317
+ function isAsyncIterableValue(value) {
318
+ return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
319
+ }
320
+ function isReadableSource(value) {
321
+ return value instanceof stream.Readable;
322
+ }
323
+ function splitChunkBuffer(chunk, chunkSizeBytes) {
324
+ if (chunk.length <= chunkSizeBytes) {
325
+ return [chunk];
326
+ }
327
+ const splitChunks = [];
328
+ for (let offset = 0; offset < chunk.length; offset += chunkSizeBytes) {
329
+ splitChunks.push(chunk.subarray(offset, offset + chunkSizeBytes));
330
+ }
331
+ return splitChunks;
332
+ }
333
+ async function* createChunkStreamIterator(source, chunkSizeBytes) {
334
+ if (Buffer.isBuffer(source)) {
335
+ yield* splitChunkBuffer(source, chunkSizeBytes);
336
+ return;
337
+ }
338
+ if (source instanceof Uint8Array) {
339
+ yield* splitChunkBuffer(
340
+ Buffer.from(source.buffer, source.byteOffset, source.byteLength),
341
+ chunkSizeBytes
342
+ );
343
+ return;
344
+ }
345
+ if (isReadableSource(source) || isAsyncIterableValue(source)) {
346
+ for await (const chunkValue of source) {
347
+ const normalizedChunk = normalizeChunkSourceValue(chunkValue);
348
+ if (normalizedChunk.length === 0) {
349
+ continue;
350
+ }
351
+ yield* splitChunkBuffer(normalizedChunk, chunkSizeBytes);
352
+ }
353
+ return;
354
+ }
355
+ throw new Error("Unsupported stream source type.");
356
+ }
357
+ function parseStreamFramePayload(data) {
358
+ if (typeof data !== "object" || data === null) {
359
+ throw new Error("Invalid stream frame payload format.");
360
+ }
361
+ const payload = data;
362
+ if (payload.version !== STREAM_FRAME_VERSION) {
363
+ throw new Error(`Unsupported stream frame version: ${String(payload.version)}.`);
364
+ }
365
+ if (typeof payload.streamId !== "string" || payload.streamId.trim().length === 0) {
366
+ throw new Error("Stream frame streamId must be a non-empty string.");
367
+ }
368
+ if (payload.type === "start") {
369
+ if (typeof payload.event !== "string" || payload.event.trim().length === 0) {
370
+ throw new Error("Stream start frame event must be a non-empty string.");
371
+ }
372
+ if (payload.totalBytes !== void 0 && (!Number.isInteger(payload.totalBytes) || payload.totalBytes < 0)) {
373
+ throw new Error("Stream start frame totalBytes must be a non-negative integer.");
374
+ }
375
+ if (payload.metadata !== void 0 && !isPlainObject(payload.metadata)) {
376
+ throw new Error("Stream start frame metadata must be a plain object when provided.");
377
+ }
378
+ return {
379
+ version: STREAM_FRAME_VERSION,
380
+ type: "start",
381
+ streamId: payload.streamId.trim(),
382
+ event: payload.event.trim(),
383
+ ...payload.metadata ? { metadata: payload.metadata } : {},
384
+ ...payload.totalBytes !== void 0 ? { totalBytes: payload.totalBytes } : {}
385
+ };
386
+ }
387
+ if (payload.type === "chunk") {
388
+ const { index, byteLength } = payload;
389
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0) {
390
+ throw new Error("Stream chunk frame index must be a non-negative integer.");
391
+ }
392
+ if (typeof payload.payload !== "string" || payload.payload.length === 0) {
393
+ throw new Error("Stream chunk frame payload must be a non-empty base64 string.");
394
+ }
395
+ if (typeof byteLength !== "number" || !Number.isInteger(byteLength) || byteLength <= 0) {
396
+ throw new Error("Stream chunk frame byteLength must be a positive integer.");
397
+ }
398
+ return {
399
+ version: STREAM_FRAME_VERSION,
400
+ type: "chunk",
401
+ streamId: payload.streamId.trim(),
402
+ index,
403
+ payload: payload.payload,
404
+ byteLength
405
+ };
406
+ }
407
+ if (payload.type === "end") {
408
+ const { chunkCount, totalBytes } = payload;
409
+ if (typeof chunkCount !== "number" || !Number.isInteger(chunkCount) || chunkCount < 0) {
410
+ throw new Error("Stream end frame chunkCount must be a non-negative integer.");
411
+ }
412
+ if (typeof totalBytes !== "number" || !Number.isInteger(totalBytes) || totalBytes < 0) {
413
+ throw new Error("Stream end frame totalBytes must be a non-negative integer.");
414
+ }
415
+ return {
416
+ version: STREAM_FRAME_VERSION,
417
+ type: "end",
418
+ streamId: payload.streamId.trim(),
419
+ chunkCount,
420
+ totalBytes
421
+ };
422
+ }
423
+ if (payload.type === "abort") {
424
+ if (typeof payload.reason !== "string" || payload.reason.trim().length === 0) {
425
+ throw new Error("Stream abort frame reason must be a non-empty string.");
426
+ }
427
+ return {
428
+ version: STREAM_FRAME_VERSION,
429
+ type: "abort",
430
+ streamId: payload.streamId.trim(),
431
+ reason: payload.reason.trim()
432
+ };
433
+ }
434
+ throw new Error("Unsupported stream frame type.");
435
+ }
436
+ async function transmitChunkedStreamFrames(event, source, options, sendFrame) {
437
+ const chunkSizeBytes = normalizeStreamChunkSize(options?.chunkSizeBytes);
438
+ const totalBytesHint = resolveKnownStreamSourceSize(source, options?.totalBytes);
439
+ if (options?.metadata !== void 0 && !isPlainObject(options.metadata)) {
440
+ throw new Error("Stream metadata must be a plain object when provided.");
441
+ }
442
+ if (options?.signal?.aborted) {
443
+ throw new Error("Stream transfer aborted before dispatch.");
444
+ }
445
+ const streamId = crypto.randomUUID();
446
+ let chunkCount = 0;
447
+ let totalBytes = 0;
448
+ await sendFrame({
449
+ version: STREAM_FRAME_VERSION,
450
+ type: "start",
451
+ streamId,
452
+ event,
453
+ ...options?.metadata ? { metadata: options.metadata } : {},
454
+ ...totalBytesHint !== void 0 ? { totalBytes: totalBytesHint } : {}
455
+ });
456
+ try {
457
+ for await (const chunkBuffer of createChunkStreamIterator(source, chunkSizeBytes)) {
458
+ if (options?.signal?.aborted) {
459
+ throw new Error("Stream transfer aborted by caller signal.");
460
+ }
461
+ if (chunkBuffer.length === 0) {
462
+ continue;
463
+ }
464
+ await sendFrame({
465
+ version: STREAM_FRAME_VERSION,
466
+ type: "chunk",
467
+ streamId,
468
+ index: chunkCount,
469
+ payload: chunkBuffer.toString("base64"),
470
+ byteLength: chunkBuffer.length
471
+ });
472
+ chunkCount += 1;
473
+ totalBytes += chunkBuffer.length;
474
+ }
475
+ if (totalBytesHint !== void 0 && totalBytes !== totalBytesHint) {
476
+ throw new Error(
477
+ `Stream totalBytes mismatch. Expected ${totalBytesHint}, received ${totalBytes}.`
478
+ );
479
+ }
480
+ await sendFrame({
481
+ version: STREAM_FRAME_VERSION,
482
+ type: "end",
483
+ streamId,
484
+ chunkCount,
485
+ totalBytes
486
+ });
487
+ return {
488
+ streamId,
489
+ chunkCount,
490
+ totalBytes
491
+ };
492
+ } catch (error) {
493
+ const normalizedError = normalizeToError(
494
+ error,
495
+ `Chunked stream transfer failed for event "${event}".`
496
+ );
497
+ try {
498
+ await sendFrame({
499
+ version: STREAM_FRAME_VERSION,
500
+ type: "abort",
501
+ streamId,
502
+ reason: normalizedError.message
503
+ });
504
+ } catch {
505
+ }
506
+ throw normalizedError;
507
+ }
508
+ }
263
509
  function parseRpcRequestPayload(data) {
264
510
  if (typeof data !== "object" || data === null) {
265
511
  throw new Error("Invalid RPC request payload format.");
@@ -339,16 +585,149 @@ function createEphemeralHandshakeState() {
339
585
  localPublicKey: ecdh.getPublicKey("base64")
340
586
  };
341
587
  }
588
+ function decodeBase64ToBuffer(value, fieldName) {
589
+ if (typeof value !== "string") {
590
+ throw new Error(`${fieldName} must be a base64 string.`);
591
+ }
592
+ const normalizedValue = value.trim();
593
+ if (normalizedValue.length === 0) {
594
+ throw new Error(`${fieldName} must be a non-empty base64 string.`);
595
+ }
596
+ const decodedBuffer = Buffer.from(normalizedValue, "base64");
597
+ if (decodedBuffer.length === 0) {
598
+ throw new Error(`${fieldName} could not be decoded from base64.`);
599
+ }
600
+ const canonicalInput = normalizedValue.replace(/=+$/u, "");
601
+ const canonicalDecoded = decodedBuffer.toString("base64").replace(/=+$/u, "");
602
+ if (canonicalInput !== canonicalDecoded) {
603
+ throw new Error(`${fieldName} is not valid base64 content.`);
604
+ }
605
+ return decodedBuffer;
606
+ }
607
+ function equalsConstantTime(left, right) {
608
+ if (left.length !== right.length) {
609
+ return false;
610
+ }
611
+ return crypto.timingSafeEqual(left, right);
612
+ }
613
+ function createResumeClientProof(sessionSecret, sessionId, clientNonce) {
614
+ return crypto.createHmac("sha256", sessionSecret).update("afx-resume-client-proof:v1").update(sessionId).update(clientNonce).digest();
615
+ }
616
+ function createResumeServerProof(resumedKey, sessionId, clientNonce) {
617
+ return crypto.createHmac("sha256", resumedKey).update("afx-resume-server-proof:v1").update(sessionId).update(clientNonce).digest();
618
+ }
619
+ function deriveSessionTicketSecret(baseKey) {
620
+ return crypto.createHmac("sha256", baseKey).update("afx-session-ticket:v1").digest();
621
+ }
622
+ function deriveResumedEncryptionKey(sessionSecret, clientNonce) {
623
+ const derivedKey = crypto.createHash("sha256").update("afx-resume-encryption-key:v1").update(sessionSecret).update(clientNonce).digest();
624
+ if (derivedKey.length !== ENCRYPTION_KEY_LENGTH) {
625
+ throw new Error("Failed to derive a valid resumed AES-256 key.");
626
+ }
627
+ return derivedKey;
628
+ }
342
629
  function parseHandshakePayload(data) {
343
630
  if (typeof data !== "object" || data === null) {
344
631
  throw new Error("Invalid handshake payload format.");
345
632
  }
346
633
  const payload = data;
347
- if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
348
- throw new Error("Handshake payload must include a non-empty public key.");
634
+ if (typeof payload.type !== "string") {
635
+ if (typeof payload.publicKey === "string" && payload.publicKey.length > 0) {
636
+ return {
637
+ type: "hello",
638
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
639
+ publicKey: payload.publicKey
640
+ };
641
+ }
642
+ throw new Error("Handshake payload must include a valid type.");
643
+ }
644
+ const protocolVersion = payload.protocolVersion === void 0 ? HANDSHAKE_PROTOCOL_VERSION : payload.protocolVersion;
645
+ if (protocolVersion !== HANDSHAKE_PROTOCOL_VERSION) {
646
+ throw new Error(
647
+ `Unsupported handshake protocol version: ${String(protocolVersion)}.`
648
+ );
649
+ }
650
+ if (payload.type === "hello") {
651
+ if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
652
+ throw new Error("Handshake hello payload must include a non-empty public key.");
653
+ }
654
+ return {
655
+ type: "hello",
656
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
657
+ publicKey: payload.publicKey
658
+ };
659
+ }
660
+ if (payload.type === "resume") {
661
+ if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
662
+ throw new Error("Handshake resume payload must include a non-empty sessionId.");
663
+ }
664
+ if (typeof payload.clientNonce !== "string" || payload.clientNonce.length === 0) {
665
+ throw new Error("Handshake resume payload must include a non-empty clientNonce.");
666
+ }
667
+ if (typeof payload.clientProof !== "string" || payload.clientProof.length === 0) {
668
+ throw new Error("Handshake resume payload must include a non-empty clientProof.");
669
+ }
670
+ return {
671
+ type: "resume",
672
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
673
+ sessionId: payload.sessionId.trim(),
674
+ clientNonce: payload.clientNonce,
675
+ clientProof: payload.clientProof
676
+ };
677
+ }
678
+ if (payload.type === "resume-ack") {
679
+ if (typeof payload.ok !== "boolean") {
680
+ throw new Error("Handshake resume-ack payload must include boolean ok.");
681
+ }
682
+ const normalizedPayload = {
683
+ type: "resume-ack",
684
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
685
+ ok: payload.ok
686
+ };
687
+ if (typeof payload.sessionId === "string" && payload.sessionId.trim().length > 0) {
688
+ normalizedPayload.sessionId = payload.sessionId.trim();
689
+ }
690
+ if (typeof payload.serverProof === "string" && payload.serverProof.length > 0) {
691
+ normalizedPayload.serverProof = payload.serverProof;
692
+ }
693
+ if (typeof payload.reason === "string" && payload.reason.trim().length > 0) {
694
+ normalizedPayload.reason = payload.reason.trim();
695
+ }
696
+ return normalizedPayload;
697
+ }
698
+ throw new Error(`Unsupported handshake payload type: ${payload.type}.`);
699
+ }
700
+ function parseSessionTicketPayload(data) {
701
+ if (typeof data !== "object" || data === null) {
702
+ throw new Error("Invalid session ticket payload format.");
703
+ }
704
+ const payload = data;
705
+ if (payload.version !== SESSION_TICKET_VERSION) {
706
+ throw new Error(
707
+ `Unsupported session ticket payload version: ${String(payload.version)}.`
708
+ );
709
+ }
710
+ if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
711
+ throw new Error("Session ticket payload must include a non-empty sessionId.");
712
+ }
713
+ if (typeof payload.secret !== "string" || payload.secret.length === 0) {
714
+ throw new Error("Session ticket payload must include a non-empty secret.");
715
+ }
716
+ if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) {
717
+ throw new Error("Session ticket payload issuedAt must be a finite number.");
718
+ }
719
+ if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) {
720
+ throw new Error("Session ticket payload expiresAt must be a finite number.");
721
+ }
722
+ if (payload.expiresAt <= payload.issuedAt) {
723
+ throw new Error("Session ticket payload expiresAt must be greater than issuedAt.");
349
724
  }
350
725
  return {
351
- publicKey: payload.publicKey
726
+ version: SESSION_TICKET_VERSION,
727
+ sessionId: payload.sessionId.trim(),
728
+ secret: payload.secret,
729
+ issuedAt: payload.issuedAt,
730
+ expiresAt: payload.expiresAt
352
731
  };
353
732
  }
354
733
  function deriveEncryptionKey(sharedSecret) {
@@ -418,10 +797,12 @@ var SecureServer = class {
418
797
  adapter = null;
419
798
  heartbeatConfig;
420
799
  rateLimitConfig;
800
+ sessionResumptionConfig;
421
801
  heartbeatIntervalHandle = null;
422
802
  clientsById = /* @__PURE__ */ new Map();
423
803
  clientIdBySocket = /* @__PURE__ */ new Map();
424
804
  customEventHandlers = /* @__PURE__ */ new Map();
805
+ streamEventHandlers = /* @__PURE__ */ new Map();
425
806
  connectionHandlers = /* @__PURE__ */ new Set();
426
807
  disconnectHandlers = /* @__PURE__ */ new Set();
427
808
  readyHandlers = /* @__PURE__ */ new Set();
@@ -432,6 +813,7 @@ var SecureServer = class {
432
813
  sharedSecretBySocket = /* @__PURE__ */ new WeakMap();
433
814
  encryptionKeyBySocket = /* @__PURE__ */ new WeakMap();
434
815
  pendingPayloadsBySocket = /* @__PURE__ */ new WeakMap();
816
+ incomingStreamsBySocket = /* @__PURE__ */ new WeakMap();
435
817
  pendingRpcRequestsBySocket = /* @__PURE__ */ new WeakMap();
436
818
  heartbeatStateBySocket = /* @__PURE__ */ new WeakMap();
437
819
  roomMembersByName = /* @__PURE__ */ new Map();
@@ -439,10 +821,12 @@ var SecureServer = class {
439
821
  clientIpByClientId = /* @__PURE__ */ new Map();
440
822
  rateLimitBucketsByClientId = /* @__PURE__ */ new Map();
441
823
  rateLimitBucketsByIp = /* @__PURE__ */ new Map();
824
+ sessionTicketStore = /* @__PURE__ */ new Map();
442
825
  constructor(options) {
443
- const { heartbeat, rateLimit, adapter, ...socketServerOptions } = options;
826
+ const { heartbeat, rateLimit, sessionResumption, adapter, ...socketServerOptions } = options;
444
827
  this.heartbeatConfig = this.resolveHeartbeatConfig(heartbeat);
445
828
  this.rateLimitConfig = this.resolveRateLimitConfig(rateLimit);
829
+ this.sessionResumptionConfig = this.resolveSessionResumptionConfig(sessionResumption);
446
830
  this.socketServer = new WebSocket.WebSocketServer(socketServerOptions);
447
831
  this.bindSocketServerEvents();
448
832
  this.startHeartbeatLoop();
@@ -528,8 +912,8 @@ var SecureServer = class {
528
912
  this.errorHandlers.add(handler);
529
913
  return this;
530
914
  }
531
- if (event === INTERNAL_HANDSHAKE_EVENT) {
532
- throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
915
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
916
+ throw new Error(`The event "${event}" is reserved for internal use.`);
533
917
  }
534
918
  const typedHandler = handler;
535
919
  const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
@@ -560,7 +944,7 @@ var SecureServer = class {
560
944
  this.errorHandlers.delete(handler);
561
945
  return this;
562
946
  }
563
- if (event === INTERNAL_HANDSHAKE_EVENT) {
947
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
564
948
  return this;
565
949
  }
566
950
  const listeners = this.customEventHandlers.get(event);
@@ -578,6 +962,38 @@ var SecureServer = class {
578
962
  }
579
963
  return this;
580
964
  }
965
+ onStream(event, handler) {
966
+ try {
967
+ if (isReservedEmitEvent(event)) {
968
+ throw new Error(`The event "${event}" is reserved and cannot be used as a stream event.`);
969
+ }
970
+ const listeners = this.streamEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
971
+ listeners.add(handler);
972
+ this.streamEventHandlers.set(event, listeners);
973
+ } catch (error) {
974
+ this.notifyError(
975
+ normalizeToError(error, "Failed to register server stream handler.")
976
+ );
977
+ }
978
+ return this;
979
+ }
980
+ offStream(event, handler) {
981
+ try {
982
+ const listeners = this.streamEventHandlers.get(event);
983
+ if (!listeners) {
984
+ return this;
985
+ }
986
+ listeners.delete(handler);
987
+ if (listeners.size === 0) {
988
+ this.streamEventHandlers.delete(event);
989
+ }
990
+ } catch (error) {
991
+ this.notifyError(
992
+ normalizeToError(error, "Failed to remove server stream handler.")
993
+ );
994
+ }
995
+ return this;
996
+ }
581
997
  use(middleware) {
582
998
  try {
583
999
  if (typeof middleware !== "function") {
@@ -653,6 +1069,40 @@ var SecureServer = class {
653
1069
  return false;
654
1070
  }
655
1071
  }
1072
+ async emitStreamTo(clientId, event, source, options) {
1073
+ try {
1074
+ if (isReservedEmitEvent(event)) {
1075
+ throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
1076
+ }
1077
+ const client = this.clientsById.get(clientId);
1078
+ if (!client) {
1079
+ throw new Error(`Client with id ${clientId} was not found.`);
1080
+ }
1081
+ if (!this.isClientHandshakeReady(client.socket)) {
1082
+ throw new Error(
1083
+ `Cannot stream event "${event}" before secure handshake completion for client ${client.id}.`
1084
+ );
1085
+ }
1086
+ return await transmitChunkedStreamFrames(
1087
+ event,
1088
+ source,
1089
+ options,
1090
+ async (framePayload) => {
1091
+ await this.sendEncryptedEnvelope(client.socket, {
1092
+ event: INTERNAL_STREAM_FRAME_EVENT,
1093
+ data: framePayload
1094
+ });
1095
+ }
1096
+ );
1097
+ } catch (error) {
1098
+ const normalizedError = normalizeToError(
1099
+ error,
1100
+ `Failed to emit chunked stream event "${event}" to client ${clientId}.`
1101
+ );
1102
+ this.notifyError(normalizedError);
1103
+ throw normalizedError;
1104
+ }
1105
+ }
656
1106
  to(room) {
657
1107
  const normalizedRoom = this.normalizeRoomName(room);
658
1108
  return {
@@ -685,6 +1135,10 @@ var SecureServer = class {
685
1135
  client.socket,
686
1136
  new Error("Server closed before ACK response was received.")
687
1137
  );
1138
+ this.cleanupIncomingStreamsForSocket(
1139
+ client.socket,
1140
+ "Server closed before stream transfer completed."
1141
+ );
688
1142
  this.middlewareMetadataBySocket.delete(client.socket);
689
1143
  if (client.socket.readyState === WebSocket__default.default.OPEN || client.socket.readyState === WebSocket__default.default.CONNECTING) {
690
1144
  client.socket.close(code, reason);
@@ -693,6 +1147,7 @@ var SecureServer = class {
693
1147
  this.rateLimitBucketsByClientId.clear();
694
1148
  this.rateLimitBucketsByIp.clear();
695
1149
  this.clientIpByClientId.clear();
1150
+ this.sessionTicketStore.clear();
696
1151
  this.socketServer.close();
697
1152
  } catch (error) {
698
1153
  this.notifyError(normalizeToError(error, "Failed to close server."));
@@ -769,6 +1224,94 @@ var SecureServer = class {
769
1224
  disconnectReason
770
1225
  };
771
1226
  }
1227
+ resolveSessionResumptionConfig(sessionResumptionOptions) {
1228
+ const ticketTtlMs = sessionResumptionOptions?.ticketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
1229
+ const maxCachedTickets = sessionResumptionOptions?.maxCachedTickets ?? DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE;
1230
+ if (!Number.isFinite(ticketTtlMs) || ticketTtlMs <= 0) {
1231
+ throw new Error(
1232
+ "Server sessionResumption ticketTtlMs must be a positive number."
1233
+ );
1234
+ }
1235
+ if (!Number.isInteger(maxCachedTickets) || maxCachedTickets <= 0) {
1236
+ throw new Error(
1237
+ "Server sessionResumption maxCachedTickets must be a positive integer."
1238
+ );
1239
+ }
1240
+ return {
1241
+ enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
1242
+ ticketTtlMs,
1243
+ maxCachedTickets
1244
+ };
1245
+ }
1246
+ pruneExpiredSessionTickets(now) {
1247
+ for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
1248
+ if (ticketRecord.expiresAt <= now) {
1249
+ this.sessionTicketStore.delete(sessionId);
1250
+ }
1251
+ }
1252
+ }
1253
+ evictSessionTicketsIfNeeded() {
1254
+ while (this.sessionTicketStore.size > this.sessionResumptionConfig.maxCachedTickets) {
1255
+ let oldestSessionId = null;
1256
+ let oldestIssuedAt = Number.POSITIVE_INFINITY;
1257
+ for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
1258
+ if (ticketRecord.issuedAt < oldestIssuedAt) {
1259
+ oldestIssuedAt = ticketRecord.issuedAt;
1260
+ oldestSessionId = sessionId;
1261
+ }
1262
+ }
1263
+ if (!oldestSessionId) {
1264
+ break;
1265
+ }
1266
+ this.sessionTicketStore.delete(oldestSessionId);
1267
+ }
1268
+ }
1269
+ getSessionTicket(sessionId) {
1270
+ const now = Date.now();
1271
+ this.pruneExpiredSessionTickets(now);
1272
+ const ticketRecord = this.sessionTicketStore.get(sessionId);
1273
+ if (!ticketRecord) {
1274
+ return null;
1275
+ }
1276
+ if (ticketRecord.expiresAt <= now) {
1277
+ this.sessionTicketStore.delete(sessionId);
1278
+ return null;
1279
+ }
1280
+ return ticketRecord;
1281
+ }
1282
+ issueSessionTicket(socket, baseKey) {
1283
+ if (!this.sessionResumptionConfig.enabled) {
1284
+ return;
1285
+ }
1286
+ const now = Date.now();
1287
+ this.pruneExpiredSessionTickets(now);
1288
+ const sessionId = crypto.randomUUID();
1289
+ const sessionSecret = deriveSessionTicketSecret(baseKey);
1290
+ const expiresAt = now + this.sessionResumptionConfig.ticketTtlMs;
1291
+ const ticketRecord = {
1292
+ sessionId,
1293
+ secret: sessionSecret,
1294
+ issuedAt: now,
1295
+ expiresAt
1296
+ };
1297
+ this.sessionTicketStore.set(sessionId, ticketRecord);
1298
+ this.evictSessionTicketsIfNeeded();
1299
+ const ticketPayload = {
1300
+ version: SESSION_TICKET_VERSION,
1301
+ sessionId,
1302
+ secret: sessionSecret.toString("base64"),
1303
+ issuedAt: now,
1304
+ expiresAt
1305
+ };
1306
+ void this.sendOrQueuePayload(socket, {
1307
+ event: INTERNAL_SESSION_TICKET_EVENT,
1308
+ data: ticketPayload
1309
+ }).catch((error) => {
1310
+ this.notifyError(
1311
+ normalizeToError(error, "Failed to deliver secure session ticket.")
1312
+ );
1313
+ });
1314
+ }
772
1315
  createRateLimitBucket(now) {
773
1316
  return {
774
1317
  windowStartedAt: now,
@@ -1139,6 +1682,18 @@ var SecureServer = class {
1139
1682
  await this.handleRpcRequest(client, decryptedEnvelope.data);
1140
1683
  return;
1141
1684
  }
1685
+ if (decryptedEnvelope.event === INTERNAL_STREAM_FRAME_EVENT) {
1686
+ this.handleIncomingStreamFrame(client, decryptedEnvelope.data);
1687
+ return;
1688
+ }
1689
+ if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
1690
+ this.notifyError(
1691
+ new Error(
1692
+ `Client ${client.id} attempted to send reserved internal session ticket event.`
1693
+ )
1694
+ );
1695
+ return;
1696
+ }
1142
1697
  const interceptedData = await this.applyMessageMiddleware(
1143
1698
  "incoming",
1144
1699
  client,
@@ -1172,6 +1727,10 @@ var SecureServer = class {
1172
1727
  this.pendingRpcRequestsBySocket.delete(client.socket);
1173
1728
  this.heartbeatStateBySocket.delete(client.socket);
1174
1729
  this.middlewareMetadataBySocket.delete(client.socket);
1730
+ this.cleanupIncomingStreamsForSocket(
1731
+ client.socket,
1732
+ `Client ${client.id} disconnected before stream transfer completed.`
1733
+ );
1175
1734
  const decodedReason = decodeCloseReason(reason);
1176
1735
  for (const handler of this.disconnectHandlers) {
1177
1736
  try {
@@ -1217,6 +1776,197 @@ var SecureServer = class {
1217
1776
  }
1218
1777
  }
1219
1778
  }
1779
+ getOrCreateIncomingServerStreams(socket) {
1780
+ const existingStreams = this.incomingStreamsBySocket.get(socket);
1781
+ if (existingStreams) {
1782
+ return existingStreams;
1783
+ }
1784
+ const streamMap = /* @__PURE__ */ new Map();
1785
+ this.incomingStreamsBySocket.set(socket, streamMap);
1786
+ return streamMap;
1787
+ }
1788
+ cleanupIncomingStreamsForSocket(socket, reason) {
1789
+ const streamMap = this.incomingStreamsBySocket.get(socket);
1790
+ if (!streamMap) {
1791
+ return;
1792
+ }
1793
+ for (const streamState of streamMap.values()) {
1794
+ streamState.stream.destroy(new Error(reason));
1795
+ }
1796
+ streamMap.clear();
1797
+ this.incomingStreamsBySocket.delete(socket);
1798
+ }
1799
+ abortIncomingServerStream(socket, streamId, reason) {
1800
+ const streamMap = this.incomingStreamsBySocket.get(socket);
1801
+ if (!streamMap) {
1802
+ return;
1803
+ }
1804
+ const streamState = streamMap.get(streamId);
1805
+ if (!streamState) {
1806
+ return;
1807
+ }
1808
+ streamState.stream.destroy(new Error(reason));
1809
+ streamMap.delete(streamId);
1810
+ if (streamMap.size === 0) {
1811
+ this.incomingStreamsBySocket.delete(socket);
1812
+ }
1813
+ }
1814
+ dispatchServerStreamEvent(event, stream, info, client) {
1815
+ const handlers = this.streamEventHandlers.get(event);
1816
+ if (!handlers || handlers.size === 0) {
1817
+ stream.resume();
1818
+ this.notifyError(
1819
+ new Error(
1820
+ `No stream handler is registered for event "${event}" on server client ${client.id}.`
1821
+ )
1822
+ );
1823
+ return;
1824
+ }
1825
+ for (const handler of handlers) {
1826
+ try {
1827
+ const handlerResult = handler(stream, info, client);
1828
+ if (isPromiseLike(handlerResult)) {
1829
+ void Promise.resolve(handlerResult).catch((error) => {
1830
+ this.notifyError(
1831
+ normalizeToError(
1832
+ error,
1833
+ `Server stream handler failed for event ${event}.`
1834
+ )
1835
+ );
1836
+ });
1837
+ }
1838
+ } catch (error) {
1839
+ this.notifyError(
1840
+ normalizeToError(
1841
+ error,
1842
+ `Server stream handler failed for event ${event}.`
1843
+ )
1844
+ );
1845
+ }
1846
+ }
1847
+ }
1848
+ handleIncomingStreamStartFrame(client, framePayload) {
1849
+ if (isReservedEmitEvent(framePayload.event)) {
1850
+ throw new Error(
1851
+ `Reserved event "${framePayload.event}" cannot be used for stream transport.`
1852
+ );
1853
+ }
1854
+ const incomingStreams = this.getOrCreateIncomingServerStreams(client.socket);
1855
+ if (incomingStreams.has(framePayload.streamId)) {
1856
+ throw new Error(
1857
+ `Stream ${framePayload.streamId} already exists for client ${client.id}.`
1858
+ );
1859
+ }
1860
+ const stream$1 = new stream.PassThrough();
1861
+ const streamInfo = {
1862
+ streamId: framePayload.streamId,
1863
+ event: framePayload.event,
1864
+ startedAt: Date.now(),
1865
+ ...framePayload.metadata !== void 0 ? { metadata: framePayload.metadata } : {},
1866
+ ...framePayload.totalBytes !== void 0 ? { totalBytes: framePayload.totalBytes } : {}
1867
+ };
1868
+ incomingStreams.set(framePayload.streamId, {
1869
+ info: streamInfo,
1870
+ stream: stream$1,
1871
+ expectedChunkIndex: 0,
1872
+ receivedBytes: 0
1873
+ });
1874
+ this.dispatchServerStreamEvent(framePayload.event, stream$1, streamInfo, client);
1875
+ }
1876
+ handleIncomingStreamChunkFrame(client, framePayload) {
1877
+ const incomingStreams = this.incomingStreamsBySocket.get(client.socket);
1878
+ const streamState = incomingStreams?.get(framePayload.streamId);
1879
+ if (!incomingStreams || !streamState) {
1880
+ throw new Error(
1881
+ `Stream ${framePayload.streamId} is unknown for client ${client.id}.`
1882
+ );
1883
+ }
1884
+ if (framePayload.index !== streamState.expectedChunkIndex) {
1885
+ throw new Error(
1886
+ `Out-of-order chunk index for stream ${framePayload.streamId}. Expected ${streamState.expectedChunkIndex}, received ${framePayload.index}.`
1887
+ );
1888
+ }
1889
+ const chunkBuffer = decodeBase64ToBuffer(
1890
+ framePayload.payload,
1891
+ `Stream chunk payload (${framePayload.streamId})`
1892
+ );
1893
+ if (chunkBuffer.length !== framePayload.byteLength) {
1894
+ throw new Error(
1895
+ `Stream ${framePayload.streamId} byteLength mismatch. Expected ${framePayload.byteLength}, received ${chunkBuffer.length}.`
1896
+ );
1897
+ }
1898
+ streamState.expectedChunkIndex += 1;
1899
+ streamState.receivedBytes += chunkBuffer.length;
1900
+ streamState.stream.write(chunkBuffer);
1901
+ }
1902
+ handleIncomingStreamEndFrame(client, framePayload) {
1903
+ const incomingStreams = this.incomingStreamsBySocket.get(client.socket);
1904
+ const streamState = incomingStreams?.get(framePayload.streamId);
1905
+ if (!incomingStreams || !streamState) {
1906
+ throw new Error(
1907
+ `Stream ${framePayload.streamId} is unknown for client ${client.id}.`
1908
+ );
1909
+ }
1910
+ if (framePayload.chunkCount !== streamState.expectedChunkIndex) {
1911
+ throw new Error(
1912
+ `Stream ${framePayload.streamId} chunkCount mismatch. Expected ${streamState.expectedChunkIndex}, received ${framePayload.chunkCount}.`
1913
+ );
1914
+ }
1915
+ if (framePayload.totalBytes !== streamState.receivedBytes) {
1916
+ throw new Error(
1917
+ `Stream ${framePayload.streamId} totalBytes mismatch. Expected ${streamState.receivedBytes}, received ${framePayload.totalBytes}.`
1918
+ );
1919
+ }
1920
+ if (streamState.info.totalBytes !== void 0 && streamState.info.totalBytes !== streamState.receivedBytes) {
1921
+ throw new Error(
1922
+ `Stream ${framePayload.streamId} violated announced totalBytes (${streamState.info.totalBytes}).`
1923
+ );
1924
+ }
1925
+ streamState.stream.end();
1926
+ incomingStreams.delete(framePayload.streamId);
1927
+ if (incomingStreams.size === 0) {
1928
+ this.incomingStreamsBySocket.delete(client.socket);
1929
+ }
1930
+ }
1931
+ handleIncomingStreamAbortFrame(client, framePayload) {
1932
+ this.abortIncomingServerStream(
1933
+ client.socket,
1934
+ framePayload.streamId,
1935
+ framePayload.reason
1936
+ );
1937
+ }
1938
+ handleIncomingStreamFrame(client, data) {
1939
+ let framePayload = null;
1940
+ try {
1941
+ framePayload = parseStreamFramePayload(data);
1942
+ if (framePayload.type === "start") {
1943
+ this.handleIncomingStreamStartFrame(client, framePayload);
1944
+ return;
1945
+ }
1946
+ if (framePayload.type === "chunk") {
1947
+ this.handleIncomingStreamChunkFrame(client, framePayload);
1948
+ return;
1949
+ }
1950
+ if (framePayload.type === "end") {
1951
+ this.handleIncomingStreamEndFrame(client, framePayload);
1952
+ return;
1953
+ }
1954
+ this.handleIncomingStreamAbortFrame(client, framePayload);
1955
+ } catch (error) {
1956
+ const normalizedError = normalizeToError(
1957
+ error,
1958
+ `Failed to process incoming stream frame for client ${client.id}.`
1959
+ );
1960
+ if (framePayload) {
1961
+ this.abortIncomingServerStream(
1962
+ client.socket,
1963
+ framePayload.streamId,
1964
+ normalizedError.message
1965
+ );
1966
+ }
1967
+ this.notifyError(normalizedError);
1968
+ }
1969
+ }
1220
1970
  async executeServerMiddleware(context) {
1221
1971
  if (this.middlewareHandlers.length === 0) {
1222
1972
  return;
@@ -1452,10 +2202,104 @@ var SecureServer = class {
1452
2202
  this.sendRaw(
1453
2203
  socket,
1454
2204
  serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
2205
+ type: "hello",
2206
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
1455
2207
  publicKey: localPublicKey
1456
2208
  })
1457
2209
  );
1458
2210
  }
2211
+ sendResumeAck(socket, payload) {
2212
+ const responsePayload = {
2213
+ type: "resume-ack",
2214
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
2215
+ ok: payload.ok
2216
+ };
2217
+ if (payload.sessionId !== void 0 && payload.sessionId.length > 0) {
2218
+ responsePayload.sessionId = payload.sessionId;
2219
+ }
2220
+ if (payload.serverProof !== void 0 && payload.serverProof.length > 0) {
2221
+ responsePayload.serverProof = payload.serverProof;
2222
+ }
2223
+ if (payload.reason !== void 0 && payload.reason.length > 0) {
2224
+ responsePayload.reason = payload.reason;
2225
+ }
2226
+ this.sendRaw(
2227
+ socket,
2228
+ serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, responsePayload)
2229
+ );
2230
+ }
2231
+ handleResumeHandshake(client, payload) {
2232
+ if (!this.sessionResumptionConfig.enabled) {
2233
+ this.sendResumeAck(client.socket, {
2234
+ ok: false,
2235
+ reason: "Session resumption is disabled."
2236
+ });
2237
+ return;
2238
+ }
2239
+ const ticketRecord = this.getSessionTicket(payload.sessionId);
2240
+ if (!ticketRecord) {
2241
+ this.sendResumeAck(client.socket, {
2242
+ ok: false,
2243
+ reason: "Session ticket is unknown or expired."
2244
+ });
2245
+ return;
2246
+ }
2247
+ try {
2248
+ const clientNonce = decodeBase64ToBuffer(
2249
+ payload.clientNonce,
2250
+ "Handshake resume clientNonce"
2251
+ );
2252
+ if (clientNonce.length !== RESUMPTION_NONCE_LENGTH) {
2253
+ throw new Error(
2254
+ `Handshake resume clientNonce must be ${RESUMPTION_NONCE_LENGTH} bytes.`
2255
+ );
2256
+ }
2257
+ const receivedProof = decodeBase64ToBuffer(
2258
+ payload.clientProof,
2259
+ "Handshake resume clientProof"
2260
+ );
2261
+ const expectedProof = createResumeClientProof(
2262
+ ticketRecord.secret,
2263
+ ticketRecord.sessionId,
2264
+ clientNonce
2265
+ );
2266
+ if (!equalsConstantTime(receivedProof, expectedProof)) {
2267
+ this.sendResumeAck(client.socket, {
2268
+ ok: false,
2269
+ reason: "Session resumption proof validation failed."
2270
+ });
2271
+ return;
2272
+ }
2273
+ this.sessionTicketStore.delete(ticketRecord.sessionId);
2274
+ const resumedKey = deriveResumedEncryptionKey(ticketRecord.secret, clientNonce);
2275
+ const serverProof = createResumeServerProof(
2276
+ resumedKey,
2277
+ ticketRecord.sessionId,
2278
+ clientNonce
2279
+ ).toString("base64");
2280
+ const handshakeState = this.handshakeStateBySocket.get(client.socket);
2281
+ if (!handshakeState) {
2282
+ throw new Error(`Missing handshake state for client ${client.id}.`);
2283
+ }
2284
+ this.sharedSecretBySocket.set(client.socket, resumedKey);
2285
+ this.encryptionKeyBySocket.set(client.socket, resumedKey);
2286
+ handshakeState.isReady = true;
2287
+ this.sendResumeAck(client.socket, {
2288
+ ok: true,
2289
+ sessionId: ticketRecord.sessionId,
2290
+ serverProof
2291
+ });
2292
+ void this.flushQueuedPayloads(client.socket);
2293
+ this.notifyReady(client);
2294
+ this.issueSessionTicket(client.socket, resumedKey);
2295
+ } catch (error) {
2296
+ this.sendResumeAck(client.socket, {
2297
+ ok: false,
2298
+ reason: "Session resumption payload was invalid."
2299
+ });
2300
+ this.notifyError(normalizeToError(error, "Failed to resume secure server session."));
2301
+ }
2302
+ }
1459
2303
  handleInternalHandshake(client, data) {
1460
2304
  try {
1461
2305
  const payload = parseHandshakePayload(data);
@@ -1466,14 +2310,22 @@ var SecureServer = class {
1466
2310
  if (handshakeState.isReady) {
1467
2311
  return;
1468
2312
  }
2313
+ if (payload.type === "resume") {
2314
+ this.handleResumeHandshake(client, payload);
2315
+ return;
2316
+ }
2317
+ if (payload.type === "resume-ack") {
2318
+ throw new Error("SecureServer received unexpected resume-ack handshake payload.");
2319
+ }
1469
2320
  const remotePublicKey = Buffer.from(payload.publicKey, "base64");
1470
2321
  const sharedSecret = handshakeState.ecdh.computeSecret(remotePublicKey);
1471
2322
  const encryptionKey = deriveEncryptionKey(sharedSecret);
1472
2323
  this.sharedSecretBySocket.set(client.socket, sharedSecret);
1473
2324
  this.encryptionKeyBySocket.set(client.socket, encryptionKey);
1474
2325
  handshakeState.isReady = true;
1475
- this.flushQueuedPayloads(client.socket);
2326
+ void this.flushQueuedPayloads(client.socket);
1476
2327
  this.notifyReady(client);
2328
+ this.issueSessionTicket(client.socket, encryptionKey);
1477
2329
  } catch (error) {
1478
2330
  this.notifyError(normalizeToError(error, "Failed to complete server handshake."));
1479
2331
  }
@@ -1543,6 +2395,9 @@ var SecureServer = class {
1543
2395
  }
1544
2396
  return this.emitTo(clientId, event, data, callbackOrOptions ?? {});
1545
2397
  },
2398
+ emitStream: (event, source, options) => {
2399
+ return this.emitStreamTo(clientId, event, source, options);
2400
+ },
1546
2401
  join: (room) => this.joinClientToRoom(clientId, room),
1547
2402
  leave: (room) => this.leaveClientFromRoom(clientId, room),
1548
2403
  leaveAll: () => this.leaveClientFromAllRooms(clientId)
@@ -1687,6 +2542,9 @@ var SecureClient = class {
1687
2542
  this.url = url;
1688
2543
  this.options = options;
1689
2544
  this.reconnectConfig = this.resolveReconnectConfig(this.options.reconnect);
2545
+ this.sessionResumptionConfig = this.resolveSessionResumptionConfig(
2546
+ this.options.sessionResumption
2547
+ );
1690
2548
  if (this.options.autoConnect ?? true) {
1691
2549
  this.connect();
1692
2550
  }
@@ -1695,10 +2553,12 @@ var SecureClient = class {
1695
2553
  options;
1696
2554
  socket = null;
1697
2555
  reconnectConfig;
2556
+ sessionResumptionConfig;
1698
2557
  reconnectAttemptCount = 0;
1699
2558
  reconnectTimer = null;
1700
2559
  isManualDisconnectRequested = false;
1701
2560
  customEventHandlers = /* @__PURE__ */ new Map();
2561
+ streamEventHandlers = /* @__PURE__ */ new Map();
1702
2562
  connectHandlers = /* @__PURE__ */ new Set();
1703
2563
  disconnectHandlers = /* @__PURE__ */ new Set();
1704
2564
  readyHandlers = /* @__PURE__ */ new Set();
@@ -1706,6 +2566,8 @@ var SecureClient = class {
1706
2566
  handshakeState = null;
1707
2567
  pendingPayloadQueue = [];
1708
2568
  pendingRpcRequests = /* @__PURE__ */ new Map();
2569
+ incomingStreams = /* @__PURE__ */ new Map();
2570
+ sessionTicket = null;
1709
2571
  get readyState() {
1710
2572
  return this.socket?.readyState ?? null;
1711
2573
  }
@@ -1764,8 +2626,8 @@ var SecureClient = class {
1764
2626
  this.errorHandlers.add(handler);
1765
2627
  return this;
1766
2628
  }
1767
- if (event === INTERNAL_HANDSHAKE_EVENT) {
1768
- throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
2629
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
2630
+ throw new Error(`The event "${event}" is reserved for internal use.`);
1769
2631
  }
1770
2632
  const typedHandler = handler;
1771
2633
  const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
@@ -1796,7 +2658,7 @@ var SecureClient = class {
1796
2658
  this.errorHandlers.delete(handler);
1797
2659
  return this;
1798
2660
  }
1799
- if (event === INTERNAL_HANDSHAKE_EVENT) {
2661
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
1800
2662
  return this;
1801
2663
  }
1802
2664
  const listeners = this.customEventHandlers.get(event);
@@ -1814,6 +2676,38 @@ var SecureClient = class {
1814
2676
  }
1815
2677
  return this;
1816
2678
  }
2679
+ onStream(event, handler) {
2680
+ try {
2681
+ if (isReservedEmitEvent(event)) {
2682
+ throw new Error(`The event "${event}" is reserved and cannot be used as a stream event.`);
2683
+ }
2684
+ const listeners = this.streamEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
2685
+ listeners.add(handler);
2686
+ this.streamEventHandlers.set(event, listeners);
2687
+ } catch (error) {
2688
+ this.notifyError(
2689
+ normalizeToError(error, "Failed to register client stream handler.")
2690
+ );
2691
+ }
2692
+ return this;
2693
+ }
2694
+ offStream(event, handler) {
2695
+ try {
2696
+ const listeners = this.streamEventHandlers.get(event);
2697
+ if (!listeners) {
2698
+ return this;
2699
+ }
2700
+ listeners.delete(handler);
2701
+ if (listeners.size === 0) {
2702
+ this.streamEventHandlers.delete(event);
2703
+ }
2704
+ } catch (error) {
2705
+ this.notifyError(
2706
+ normalizeToError(error, "Failed to remove client stream handler.")
2707
+ );
2708
+ }
2709
+ return this;
2710
+ }
1817
2711
  emit(event, data, callbackOrOptions, maybeCallback) {
1818
2712
  const ackArgs = resolveAckArguments(callbackOrOptions, maybeCallback);
1819
2713
  try {
@@ -1859,6 +2753,39 @@ var SecureClient = class {
1859
2753
  return false;
1860
2754
  }
1861
2755
  }
2756
+ async emitStream(event, source, options) {
2757
+ try {
2758
+ if (isReservedEmitEvent(event)) {
2759
+ throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
2760
+ }
2761
+ if (!this.socket || this.socket.readyState !== WebSocket__default.default.OPEN) {
2762
+ throw new Error("Client socket is not connected.");
2763
+ }
2764
+ if (!this.isHandshakeReady()) {
2765
+ throw new Error(
2766
+ `Cannot stream event "${event}" before secure handshake completion.`
2767
+ );
2768
+ }
2769
+ return await transmitChunkedStreamFrames(
2770
+ event,
2771
+ source,
2772
+ options,
2773
+ async (framePayload) => {
2774
+ await this.sendEncryptedEnvelope({
2775
+ event: INTERNAL_STREAM_FRAME_EVENT,
2776
+ data: framePayload
2777
+ });
2778
+ }
2779
+ );
2780
+ } catch (error) {
2781
+ const normalizedError = normalizeToError(
2782
+ error,
2783
+ `Failed to emit chunked stream event "${event}".`
2784
+ );
2785
+ this.notifyError(normalizedError);
2786
+ throw normalizedError;
2787
+ }
2788
+ }
1862
2789
  resolveReconnectConfig(reconnectOptions) {
1863
2790
  if (typeof reconnectOptions === "boolean") {
1864
2791
  return {
@@ -1902,6 +2829,24 @@ var SecureClient = class {
1902
2829
  maxAttempts
1903
2830
  };
1904
2831
  }
2832
+ resolveSessionResumptionConfig(sessionResumptionOptions) {
2833
+ if (typeof sessionResumptionOptions === "boolean") {
2834
+ return {
2835
+ enabled: sessionResumptionOptions,
2836
+ maxAcceptedTicketTtlMs: DEFAULT_SESSION_TICKET_TTL_MS
2837
+ };
2838
+ }
2839
+ const maxAcceptedTicketTtlMs = sessionResumptionOptions?.maxAcceptedTicketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
2840
+ if (!Number.isFinite(maxAcceptedTicketTtlMs) || maxAcceptedTicketTtlMs <= 0) {
2841
+ throw new Error(
2842
+ "Client sessionResumption maxAcceptedTicketTtlMs must be a positive number."
2843
+ );
2844
+ }
2845
+ return {
2846
+ enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
2847
+ maxAcceptedTicketTtlMs
2848
+ };
2849
+ }
1905
2850
  scheduleReconnect() {
1906
2851
  if (!this.reconnectConfig.enabled || this.reconnectTimer) {
1907
2852
  return;
@@ -1949,7 +2894,6 @@ var SecureClient = class {
1949
2894
  socket.on("open", () => {
1950
2895
  this.clearReconnectTimer();
1951
2896
  this.reconnectAttemptCount = 0;
1952
- this.sendInternalHandshake();
1953
2897
  this.notifyConnect();
1954
2898
  });
1955
2899
  socket.on("message", (rawData) => {
@@ -2005,6 +2949,14 @@ var SecureClient = class {
2005
2949
  void this.handleRpcRequest(decryptedEnvelope.data);
2006
2950
  return;
2007
2951
  }
2952
+ if (decryptedEnvelope.event === INTERNAL_STREAM_FRAME_EVENT) {
2953
+ this.handleIncomingStreamFrame(decryptedEnvelope.data);
2954
+ return;
2955
+ }
2956
+ if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
2957
+ this.handleSessionTicket(decryptedEnvelope.data);
2958
+ return;
2959
+ }
2008
2960
  this.dispatchCustomEvent(decryptedEnvelope.event, decryptedEnvelope.data);
2009
2961
  } catch (error) {
2010
2962
  this.notifyError(normalizeToError(error, "Failed to process incoming client message."));
@@ -2015,6 +2967,9 @@ var SecureClient = class {
2015
2967
  this.socket = null;
2016
2968
  this.handshakeState = null;
2017
2969
  this.pendingPayloadQueue = [];
2970
+ this.cleanupIncomingStreams(
2971
+ "Client disconnected before stream transfer completed."
2972
+ );
2018
2973
  this.rejectPendingRpcRequests(
2019
2974
  new Error("Client disconnected before ACK response was received.")
2020
2975
  );
@@ -2061,6 +3016,151 @@ var SecureClient = class {
2061
3016
  }
2062
3017
  }
2063
3018
  }
3019
+ cleanupIncomingStreams(reason) {
3020
+ for (const streamState of this.incomingStreams.values()) {
3021
+ streamState.stream.destroy(new Error(reason));
3022
+ }
3023
+ this.incomingStreams.clear();
3024
+ }
3025
+ abortIncomingClientStream(streamId, reason) {
3026
+ const streamState = this.incomingStreams.get(streamId);
3027
+ if (!streamState) {
3028
+ return;
3029
+ }
3030
+ streamState.stream.destroy(new Error(reason));
3031
+ this.incomingStreams.delete(streamId);
3032
+ }
3033
+ dispatchClientStreamEvent(event, stream, info) {
3034
+ const handlers = this.streamEventHandlers.get(event);
3035
+ if (!handlers || handlers.size === 0) {
3036
+ stream.resume();
3037
+ this.notifyError(
3038
+ new Error(`No stream handler is registered for event "${event}" on client.`)
3039
+ );
3040
+ return;
3041
+ }
3042
+ for (const handler of handlers) {
3043
+ try {
3044
+ const handlerResult = handler(stream, info);
3045
+ if (isPromiseLike(handlerResult)) {
3046
+ void Promise.resolve(handlerResult).catch((error) => {
3047
+ this.notifyError(
3048
+ normalizeToError(
3049
+ error,
3050
+ `Client stream handler failed for event ${event}.`
3051
+ )
3052
+ );
3053
+ });
3054
+ }
3055
+ } catch (error) {
3056
+ this.notifyError(
3057
+ normalizeToError(error, `Client stream handler failed for event ${event}.`)
3058
+ );
3059
+ }
3060
+ }
3061
+ }
3062
+ handleIncomingClientStreamStartFrame(framePayload) {
3063
+ if (isReservedEmitEvent(framePayload.event)) {
3064
+ throw new Error(
3065
+ `Reserved event "${framePayload.event}" cannot be used for stream transport.`
3066
+ );
3067
+ }
3068
+ if (this.incomingStreams.has(framePayload.streamId)) {
3069
+ throw new Error(`Stream ${framePayload.streamId} already exists on client.`);
3070
+ }
3071
+ const stream$1 = new stream.PassThrough();
3072
+ const streamInfo = {
3073
+ streamId: framePayload.streamId,
3074
+ event: framePayload.event,
3075
+ startedAt: Date.now(),
3076
+ ...framePayload.metadata !== void 0 ? { metadata: framePayload.metadata } : {},
3077
+ ...framePayload.totalBytes !== void 0 ? { totalBytes: framePayload.totalBytes } : {}
3078
+ };
3079
+ this.incomingStreams.set(framePayload.streamId, {
3080
+ info: streamInfo,
3081
+ stream: stream$1,
3082
+ expectedChunkIndex: 0,
3083
+ receivedBytes: 0
3084
+ });
3085
+ this.dispatchClientStreamEvent(framePayload.event, stream$1, streamInfo);
3086
+ }
3087
+ handleIncomingClientStreamChunkFrame(framePayload) {
3088
+ const streamState = this.incomingStreams.get(framePayload.streamId);
3089
+ if (!streamState) {
3090
+ throw new Error(`Stream ${framePayload.streamId} is unknown on client.`);
3091
+ }
3092
+ if (framePayload.index !== streamState.expectedChunkIndex) {
3093
+ throw new Error(
3094
+ `Out-of-order chunk index for stream ${framePayload.streamId}. Expected ${streamState.expectedChunkIndex}, received ${framePayload.index}.`
3095
+ );
3096
+ }
3097
+ const chunkBuffer = decodeBase64ToBuffer(
3098
+ framePayload.payload,
3099
+ `Stream chunk payload (${framePayload.streamId})`
3100
+ );
3101
+ if (chunkBuffer.length !== framePayload.byteLength) {
3102
+ throw new Error(
3103
+ `Stream ${framePayload.streamId} byteLength mismatch. Expected ${framePayload.byteLength}, received ${chunkBuffer.length}.`
3104
+ );
3105
+ }
3106
+ streamState.expectedChunkIndex += 1;
3107
+ streamState.receivedBytes += chunkBuffer.length;
3108
+ streamState.stream.write(chunkBuffer);
3109
+ }
3110
+ handleIncomingClientStreamEndFrame(framePayload) {
3111
+ const streamState = this.incomingStreams.get(framePayload.streamId);
3112
+ if (!streamState) {
3113
+ throw new Error(`Stream ${framePayload.streamId} is unknown on client.`);
3114
+ }
3115
+ if (framePayload.chunkCount !== streamState.expectedChunkIndex) {
3116
+ throw new Error(
3117
+ `Stream ${framePayload.streamId} chunkCount mismatch. Expected ${streamState.expectedChunkIndex}, received ${framePayload.chunkCount}.`
3118
+ );
3119
+ }
3120
+ if (framePayload.totalBytes !== streamState.receivedBytes) {
3121
+ throw new Error(
3122
+ `Stream ${framePayload.streamId} totalBytes mismatch. Expected ${streamState.receivedBytes}, received ${framePayload.totalBytes}.`
3123
+ );
3124
+ }
3125
+ if (streamState.info.totalBytes !== void 0 && streamState.info.totalBytes !== streamState.receivedBytes) {
3126
+ throw new Error(
3127
+ `Stream ${framePayload.streamId} violated announced totalBytes (${streamState.info.totalBytes}).`
3128
+ );
3129
+ }
3130
+ streamState.stream.end();
3131
+ this.incomingStreams.delete(framePayload.streamId);
3132
+ }
3133
+ handleIncomingClientStreamAbortFrame(framePayload) {
3134
+ this.abortIncomingClientStream(framePayload.streamId, framePayload.reason);
3135
+ }
3136
+ handleIncomingStreamFrame(data) {
3137
+ let framePayload = null;
3138
+ try {
3139
+ framePayload = parseStreamFramePayload(data);
3140
+ if (framePayload.type === "start") {
3141
+ this.handleIncomingClientStreamStartFrame(framePayload);
3142
+ return;
3143
+ }
3144
+ if (framePayload.type === "chunk") {
3145
+ this.handleIncomingClientStreamChunkFrame(framePayload);
3146
+ return;
3147
+ }
3148
+ if (framePayload.type === "end") {
3149
+ this.handleIncomingClientStreamEndFrame(framePayload);
3150
+ return;
3151
+ }
3152
+ this.handleIncomingClientStreamAbortFrame(framePayload);
3153
+ } catch (error) {
3154
+ const normalizedError = normalizeToError(
3155
+ error,
3156
+ "Failed to process incoming stream frame on client."
3157
+ );
3158
+ if (framePayload) {
3159
+ this.abortIncomingClientStream(framePayload.streamId, normalizedError.message);
3160
+ }
3161
+ this.notifyError(normalizedError);
3162
+ }
3163
+ }
2064
3164
  notifyConnect() {
2065
3165
  for (const handler of this.connectHandlers) {
2066
3166
  try {
@@ -2218,11 +3318,45 @@ var SecureClient = class {
2218
3318
  }
2219
3319
  this.pendingRpcRequests.clear();
2220
3320
  }
3321
+ handleSessionTicket(data) {
3322
+ if (!this.sessionResumptionConfig.enabled) {
3323
+ return;
3324
+ }
3325
+ try {
3326
+ const ticketPayload = parseSessionTicketPayload(data);
3327
+ const now = Date.now();
3328
+ if (ticketPayload.expiresAt <= now) {
3329
+ return;
3330
+ }
3331
+ const ticketTtlMs = ticketPayload.expiresAt - ticketPayload.issuedAt;
3332
+ if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
3333
+ throw new Error("Session ticket TTL exceeds client trust policy.");
3334
+ }
3335
+ const sessionSecret = decodeBase64ToBuffer(
3336
+ ticketPayload.secret,
3337
+ "Session ticket secret"
3338
+ );
3339
+ if (sessionSecret.length !== ENCRYPTION_KEY_LENGTH) {
3340
+ throw new Error("Session ticket secret has invalid length.");
3341
+ }
3342
+ this.sessionTicket = {
3343
+ sessionId: ticketPayload.sessionId,
3344
+ secret: sessionSecret,
3345
+ issuedAt: ticketPayload.issuedAt,
3346
+ expiresAt: ticketPayload.expiresAt
3347
+ };
3348
+ } catch (error) {
3349
+ this.notifyError(normalizeToError(error, "Failed to process session ticket payload."));
3350
+ }
3351
+ }
2221
3352
  createClientHandshakeState() {
2222
3353
  const { ecdh, localPublicKey } = createEphemeralHandshakeState();
2223
3354
  return {
2224
3355
  ecdh,
2225
3356
  localPublicKey,
3357
+ clientHelloSent: false,
3358
+ pendingServerPublicKey: null,
3359
+ resumeAttempt: null,
2226
3360
  isReady: false,
2227
3361
  sharedSecret: null,
2228
3362
  encryptionKey: null
@@ -2236,15 +3370,171 @@ var SecureClient = class {
2236
3370
  if (!this.handshakeState) {
2237
3371
  throw new Error("Missing client handshake state.");
2238
3372
  }
3373
+ if (this.handshakeState.clientHelloSent) {
3374
+ return;
3375
+ }
2239
3376
  this.socket.send(
2240
3377
  serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
3378
+ type: "hello",
3379
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
2241
3380
  publicKey: this.handshakeState.localPublicKey
2242
3381
  })
2243
3382
  );
3383
+ this.handshakeState.clientHelloSent = true;
2244
3384
  } catch (error) {
2245
3385
  this.notifyError(normalizeToError(error, "Failed to send client handshake payload."));
2246
3386
  }
2247
3387
  }
3388
+ shouldAttemptSessionResumption() {
3389
+ if (!this.sessionResumptionConfig.enabled) {
3390
+ return false;
3391
+ }
3392
+ const sessionTicket = this.sessionTicket;
3393
+ if (!sessionTicket) {
3394
+ return false;
3395
+ }
3396
+ const now = Date.now();
3397
+ if (sessionTicket.expiresAt <= now) {
3398
+ this.sessionTicket = null;
3399
+ return false;
3400
+ }
3401
+ const ticketTtlMs = sessionTicket.expiresAt - sessionTicket.issuedAt;
3402
+ if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
3403
+ this.sessionTicket = null;
3404
+ return false;
3405
+ }
3406
+ return true;
3407
+ }
3408
+ sendResumeHandshake() {
3409
+ if (!this.socket || this.socket.readyState !== WebSocket__default.default.OPEN) {
3410
+ return false;
3411
+ }
3412
+ if (!this.handshakeState || !this.sessionTicket) {
3413
+ return false;
3414
+ }
3415
+ if (this.handshakeState.clientHelloSent) {
3416
+ return false;
3417
+ }
3418
+ if (this.handshakeState.resumeAttempt?.status === "pending") {
3419
+ return true;
3420
+ }
3421
+ try {
3422
+ const clientNonce = crypto.randomBytes(RESUMPTION_NONCE_LENGTH);
3423
+ const resumedKey = deriveResumedEncryptionKey(this.sessionTicket.secret, clientNonce);
3424
+ const clientProof = createResumeClientProof(
3425
+ this.sessionTicket.secret,
3426
+ this.sessionTicket.sessionId,
3427
+ clientNonce
3428
+ );
3429
+ this.socket.send(
3430
+ serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
3431
+ type: "resume",
3432
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
3433
+ sessionId: this.sessionTicket.sessionId,
3434
+ clientNonce: clientNonce.toString("base64"),
3435
+ clientProof: clientProof.toString("base64")
3436
+ })
3437
+ );
3438
+ this.handshakeState.resumeAttempt = {
3439
+ status: "pending",
3440
+ sessionId: this.sessionTicket.sessionId,
3441
+ clientNonce,
3442
+ resumedKey
3443
+ };
3444
+ return true;
3445
+ } catch (error) {
3446
+ this.notifyError(normalizeToError(error, "Failed to dispatch resume handshake payload."));
3447
+ this.sessionTicket = null;
3448
+ this.handshakeState.resumeAttempt = null;
3449
+ return false;
3450
+ }
3451
+ }
3452
+ completeFullHandshake(serverPublicKey) {
3453
+ if (!this.handshakeState) {
3454
+ throw new Error("Missing client handshake state.");
3455
+ }
3456
+ if (this.handshakeState.isReady) {
3457
+ return;
3458
+ }
3459
+ this.sendInternalHandshake();
3460
+ const remotePublicKey = Buffer.from(serverPublicKey, "base64");
3461
+ const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
3462
+ this.handshakeState.sharedSecret = sharedSecret;
3463
+ this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
3464
+ this.handshakeState.resumeAttempt = null;
3465
+ this.handshakeState.pendingServerPublicKey = null;
3466
+ this.handshakeState.isReady = true;
3467
+ void this.flushPendingPayloadQueue();
3468
+ this.notifyReady();
3469
+ }
3470
+ fallbackToFullHandshake() {
3471
+ if (!this.handshakeState || this.handshakeState.isReady) {
3472
+ return;
3473
+ }
3474
+ if (this.handshakeState.resumeAttempt) {
3475
+ this.handshakeState.resumeAttempt.status = "failed";
3476
+ }
3477
+ const pendingServerPublicKey = this.handshakeState.pendingServerPublicKey;
3478
+ if (pendingServerPublicKey) {
3479
+ this.completeFullHandshake(pendingServerPublicKey);
3480
+ return;
3481
+ }
3482
+ this.sendInternalHandshake();
3483
+ }
3484
+ handleServerHelloHandshake(payload) {
3485
+ if (!this.handshakeState || this.handshakeState.isReady) {
3486
+ return;
3487
+ }
3488
+ this.handshakeState.pendingServerPublicKey = payload.publicKey;
3489
+ if (this.shouldAttemptSessionResumption() && this.sendResumeHandshake()) {
3490
+ return;
3491
+ }
3492
+ this.completeFullHandshake(payload.publicKey);
3493
+ }
3494
+ handleResumeAckHandshake(payload) {
3495
+ if (!this.handshakeState || this.handshakeState.isReady) {
3496
+ return;
3497
+ }
3498
+ const resumeAttempt = this.handshakeState.resumeAttempt;
3499
+ if (!resumeAttempt || resumeAttempt.status !== "pending") {
3500
+ return;
3501
+ }
3502
+ if (!payload.ok) {
3503
+ this.sessionTicket = null;
3504
+ this.fallbackToFullHandshake();
3505
+ return;
3506
+ }
3507
+ if (payload.sessionId !== resumeAttempt.sessionId || !payload.serverProof) {
3508
+ this.sessionTicket = null;
3509
+ this.fallbackToFullHandshake();
3510
+ return;
3511
+ }
3512
+ try {
3513
+ const receivedServerProof = decodeBase64ToBuffer(
3514
+ payload.serverProof,
3515
+ "Handshake resume-ack serverProof"
3516
+ );
3517
+ const expectedServerProof = createResumeServerProof(
3518
+ resumeAttempt.resumedKey,
3519
+ resumeAttempt.sessionId,
3520
+ resumeAttempt.clientNonce
3521
+ );
3522
+ if (!equalsConstantTime(receivedServerProof, expectedServerProof)) {
3523
+ throw new Error("Resume server proof validation failed.");
3524
+ }
3525
+ this.handshakeState.sharedSecret = resumeAttempt.resumedKey;
3526
+ this.handshakeState.encryptionKey = resumeAttempt.resumedKey;
3527
+ this.handshakeState.pendingServerPublicKey = null;
3528
+ resumeAttempt.status = "accepted";
3529
+ this.handshakeState.isReady = true;
3530
+ void this.flushPendingPayloadQueue();
3531
+ this.notifyReady();
3532
+ } catch (error) {
3533
+ this.notifyError(normalizeToError(error, "Failed to verify resume server proof."));
3534
+ this.sessionTicket = null;
3535
+ this.fallbackToFullHandshake();
3536
+ }
3537
+ }
2248
3538
  handleInternalHandshake(data) {
2249
3539
  try {
2250
3540
  const payload = parseHandshakePayload(data);
@@ -2254,13 +3544,15 @@ var SecureClient = class {
2254
3544
  if (this.handshakeState.isReady) {
2255
3545
  return;
2256
3546
  }
2257
- const remotePublicKey = Buffer.from(payload.publicKey, "base64");
2258
- const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
2259
- this.handshakeState.sharedSecret = sharedSecret;
2260
- this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
2261
- this.handshakeState.isReady = true;
2262
- void this.flushPendingPayloadQueue();
2263
- this.notifyReady();
3547
+ if (payload.type === "hello") {
3548
+ this.handleServerHelloHandshake(payload);
3549
+ return;
3550
+ }
3551
+ if (payload.type === "resume-ack") {
3552
+ this.handleResumeAckHandshake(payload);
3553
+ return;
3554
+ }
3555
+ throw new Error("SecureClient received unexpected resume request handshake payload.");
2264
3556
  } catch (error) {
2265
3557
  this.notifyError(normalizeToError(error, "Failed to complete client handshake."));
2266
3558
  }