@aegis-fluxion/core 0.7.2 → 0.8.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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID, createDecipheriv, randomBytes, createCipheriv, createECDH, createHash } from 'crypto';
1
+ import { randomUUID, randomBytes, createHmac, createDecipheriv, createCipheriv, createECDH, timingSafeEqual, createHash } from 'crypto';
2
2
  import WebSocket, { WebSocketServer } from 'ws';
3
3
 
4
4
  // src/index.ts
@@ -7,20 +7,27 @@ var DEFAULT_CLOSE_REASON = "";
7
7
  var POLICY_VIOLATION_CLOSE_CODE = 1008;
8
8
  var POLICY_VIOLATION_CLOSE_REASON = "Connection rejected by middleware.";
9
9
  var INTERNAL_HANDSHAKE_EVENT = "__handshake";
10
+ var INTERNAL_SESSION_TICKET_EVENT = "__session:ticket";
10
11
  var INTERNAL_RPC_REQUEST_EVENT = "__rpc:req";
11
12
  var INTERNAL_RPC_RESPONSE_EVENT = "__rpc:res";
12
13
  var READY_EVENT = "ready";
13
14
  var HANDSHAKE_CURVE = "prime256v1";
15
+ var HANDSHAKE_PROTOCOL_VERSION = 1;
14
16
  var ENCRYPTION_ALGORITHM = "aes-256-gcm";
15
17
  var GCM_IV_LENGTH = 12;
16
18
  var GCM_AUTH_TAG_LENGTH = 16;
17
19
  var ENCRYPTION_KEY_LENGTH = 32;
18
20
  var ENCRYPTED_PACKET_VERSION = 1;
19
21
  var ENCRYPTED_PACKET_PREFIX_LENGTH = 1 + GCM_IV_LENGTH + GCM_AUTH_TAG_LENGTH;
22
+ var SESSION_TICKET_VERSION = 1;
20
23
  var BINARY_PAYLOAD_MARKER = "__afxBinaryPayload";
21
24
  var BINARY_PAYLOAD_VERSION = 1;
22
25
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
23
26
  var DEFAULT_HEARTBEAT_TIMEOUT_MS = 15e3;
27
+ var DEFAULT_SESSION_RESUMPTION_ENABLED = true;
28
+ var DEFAULT_SESSION_TICKET_TTL_MS = 10 * 6e4;
29
+ var DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE = 1e4;
30
+ var RESUMPTION_NONCE_LENGTH = 16;
24
31
  var DEFAULT_RECONNECT_INITIAL_DELAY_MS = 250;
25
32
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 1e4;
26
33
  var DEFAULT_RECONNECT_FACTOR = 2;
@@ -242,7 +249,7 @@ function decodeCloseReason(reason) {
242
249
  return reason.toString("utf8");
243
250
  }
244
251
  function isReservedEmitEvent(event) {
245
- return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === READY_EVENT;
252
+ return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === READY_EVENT;
246
253
  }
247
254
  function isPromiseLike(value) {
248
255
  return typeof value === "object" && value !== null && "then" in value;
@@ -333,16 +340,149 @@ function createEphemeralHandshakeState() {
333
340
  localPublicKey: ecdh.getPublicKey("base64")
334
341
  };
335
342
  }
343
+ function decodeBase64ToBuffer(value, fieldName) {
344
+ if (typeof value !== "string") {
345
+ throw new Error(`${fieldName} must be a base64 string.`);
346
+ }
347
+ const normalizedValue = value.trim();
348
+ if (normalizedValue.length === 0) {
349
+ throw new Error(`${fieldName} must be a non-empty base64 string.`);
350
+ }
351
+ const decodedBuffer = Buffer.from(normalizedValue, "base64");
352
+ if (decodedBuffer.length === 0) {
353
+ throw new Error(`${fieldName} could not be decoded from base64.`);
354
+ }
355
+ const canonicalInput = normalizedValue.replace(/=+$/u, "");
356
+ const canonicalDecoded = decodedBuffer.toString("base64").replace(/=+$/u, "");
357
+ if (canonicalInput !== canonicalDecoded) {
358
+ throw new Error(`${fieldName} is not valid base64 content.`);
359
+ }
360
+ return decodedBuffer;
361
+ }
362
+ function equalsConstantTime(left, right) {
363
+ if (left.length !== right.length) {
364
+ return false;
365
+ }
366
+ return timingSafeEqual(left, right);
367
+ }
368
+ function createResumeClientProof(sessionSecret, sessionId, clientNonce) {
369
+ return createHmac("sha256", sessionSecret).update("afx-resume-client-proof:v1").update(sessionId).update(clientNonce).digest();
370
+ }
371
+ function createResumeServerProof(resumedKey, sessionId, clientNonce) {
372
+ return createHmac("sha256", resumedKey).update("afx-resume-server-proof:v1").update(sessionId).update(clientNonce).digest();
373
+ }
374
+ function deriveSessionTicketSecret(baseKey) {
375
+ return createHmac("sha256", baseKey).update("afx-session-ticket:v1").digest();
376
+ }
377
+ function deriveResumedEncryptionKey(sessionSecret, clientNonce) {
378
+ const derivedKey = createHash("sha256").update("afx-resume-encryption-key:v1").update(sessionSecret).update(clientNonce).digest();
379
+ if (derivedKey.length !== ENCRYPTION_KEY_LENGTH) {
380
+ throw new Error("Failed to derive a valid resumed AES-256 key.");
381
+ }
382
+ return derivedKey;
383
+ }
336
384
  function parseHandshakePayload(data) {
337
385
  if (typeof data !== "object" || data === null) {
338
386
  throw new Error("Invalid handshake payload format.");
339
387
  }
340
388
  const payload = data;
341
- if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
342
- throw new Error("Handshake payload must include a non-empty public key.");
389
+ if (typeof payload.type !== "string") {
390
+ if (typeof payload.publicKey === "string" && payload.publicKey.length > 0) {
391
+ return {
392
+ type: "hello",
393
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
394
+ publicKey: payload.publicKey
395
+ };
396
+ }
397
+ throw new Error("Handshake payload must include a valid type.");
398
+ }
399
+ const protocolVersion = payload.protocolVersion === void 0 ? HANDSHAKE_PROTOCOL_VERSION : payload.protocolVersion;
400
+ if (protocolVersion !== HANDSHAKE_PROTOCOL_VERSION) {
401
+ throw new Error(
402
+ `Unsupported handshake protocol version: ${String(protocolVersion)}.`
403
+ );
404
+ }
405
+ if (payload.type === "hello") {
406
+ if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
407
+ throw new Error("Handshake hello payload must include a non-empty public key.");
408
+ }
409
+ return {
410
+ type: "hello",
411
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
412
+ publicKey: payload.publicKey
413
+ };
414
+ }
415
+ if (payload.type === "resume") {
416
+ if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
417
+ throw new Error("Handshake resume payload must include a non-empty sessionId.");
418
+ }
419
+ if (typeof payload.clientNonce !== "string" || payload.clientNonce.length === 0) {
420
+ throw new Error("Handshake resume payload must include a non-empty clientNonce.");
421
+ }
422
+ if (typeof payload.clientProof !== "string" || payload.clientProof.length === 0) {
423
+ throw new Error("Handshake resume payload must include a non-empty clientProof.");
424
+ }
425
+ return {
426
+ type: "resume",
427
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
428
+ sessionId: payload.sessionId.trim(),
429
+ clientNonce: payload.clientNonce,
430
+ clientProof: payload.clientProof
431
+ };
432
+ }
433
+ if (payload.type === "resume-ack") {
434
+ if (typeof payload.ok !== "boolean") {
435
+ throw new Error("Handshake resume-ack payload must include boolean ok.");
436
+ }
437
+ const normalizedPayload = {
438
+ type: "resume-ack",
439
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
440
+ ok: payload.ok
441
+ };
442
+ if (typeof payload.sessionId === "string" && payload.sessionId.trim().length > 0) {
443
+ normalizedPayload.sessionId = payload.sessionId.trim();
444
+ }
445
+ if (typeof payload.serverProof === "string" && payload.serverProof.length > 0) {
446
+ normalizedPayload.serverProof = payload.serverProof;
447
+ }
448
+ if (typeof payload.reason === "string" && payload.reason.trim().length > 0) {
449
+ normalizedPayload.reason = payload.reason.trim();
450
+ }
451
+ return normalizedPayload;
452
+ }
453
+ throw new Error(`Unsupported handshake payload type: ${payload.type}.`);
454
+ }
455
+ function parseSessionTicketPayload(data) {
456
+ if (typeof data !== "object" || data === null) {
457
+ throw new Error("Invalid session ticket payload format.");
458
+ }
459
+ const payload = data;
460
+ if (payload.version !== SESSION_TICKET_VERSION) {
461
+ throw new Error(
462
+ `Unsupported session ticket payload version: ${String(payload.version)}.`
463
+ );
464
+ }
465
+ if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
466
+ throw new Error("Session ticket payload must include a non-empty sessionId.");
467
+ }
468
+ if (typeof payload.secret !== "string" || payload.secret.length === 0) {
469
+ throw new Error("Session ticket payload must include a non-empty secret.");
470
+ }
471
+ if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) {
472
+ throw new Error("Session ticket payload issuedAt must be a finite number.");
473
+ }
474
+ if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) {
475
+ throw new Error("Session ticket payload expiresAt must be a finite number.");
476
+ }
477
+ if (payload.expiresAt <= payload.issuedAt) {
478
+ throw new Error("Session ticket payload expiresAt must be greater than issuedAt.");
343
479
  }
344
480
  return {
345
- publicKey: payload.publicKey
481
+ version: SESSION_TICKET_VERSION,
482
+ sessionId: payload.sessionId.trim(),
483
+ secret: payload.secret,
484
+ issuedAt: payload.issuedAt,
485
+ expiresAt: payload.expiresAt
346
486
  };
347
487
  }
348
488
  function deriveEncryptionKey(sharedSecret) {
@@ -412,6 +552,7 @@ var SecureServer = class {
412
552
  adapter = null;
413
553
  heartbeatConfig;
414
554
  rateLimitConfig;
555
+ sessionResumptionConfig;
415
556
  heartbeatIntervalHandle = null;
416
557
  clientsById = /* @__PURE__ */ new Map();
417
558
  clientIdBySocket = /* @__PURE__ */ new Map();
@@ -433,10 +574,12 @@ var SecureServer = class {
433
574
  clientIpByClientId = /* @__PURE__ */ new Map();
434
575
  rateLimitBucketsByClientId = /* @__PURE__ */ new Map();
435
576
  rateLimitBucketsByIp = /* @__PURE__ */ new Map();
577
+ sessionTicketStore = /* @__PURE__ */ new Map();
436
578
  constructor(options) {
437
- const { heartbeat, rateLimit, adapter, ...socketServerOptions } = options;
579
+ const { heartbeat, rateLimit, sessionResumption, adapter, ...socketServerOptions } = options;
438
580
  this.heartbeatConfig = this.resolveHeartbeatConfig(heartbeat);
439
581
  this.rateLimitConfig = this.resolveRateLimitConfig(rateLimit);
582
+ this.sessionResumptionConfig = this.resolveSessionResumptionConfig(sessionResumption);
440
583
  this.socketServer = new WebSocketServer(socketServerOptions);
441
584
  this.bindSocketServerEvents();
442
585
  this.startHeartbeatLoop();
@@ -522,8 +665,8 @@ var SecureServer = class {
522
665
  this.errorHandlers.add(handler);
523
666
  return this;
524
667
  }
525
- if (event === INTERNAL_HANDSHAKE_EVENT) {
526
- throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
668
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
669
+ throw new Error(`The event "${event}" is reserved for internal use.`);
527
670
  }
528
671
  const typedHandler = handler;
529
672
  const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
@@ -554,7 +697,7 @@ var SecureServer = class {
554
697
  this.errorHandlers.delete(handler);
555
698
  return this;
556
699
  }
557
- if (event === INTERNAL_HANDSHAKE_EVENT) {
700
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
558
701
  return this;
559
702
  }
560
703
  const listeners = this.customEventHandlers.get(event);
@@ -687,6 +830,7 @@ var SecureServer = class {
687
830
  this.rateLimitBucketsByClientId.clear();
688
831
  this.rateLimitBucketsByIp.clear();
689
832
  this.clientIpByClientId.clear();
833
+ this.sessionTicketStore.clear();
690
834
  this.socketServer.close();
691
835
  } catch (error) {
692
836
  this.notifyError(normalizeToError(error, "Failed to close server."));
@@ -763,6 +907,94 @@ var SecureServer = class {
763
907
  disconnectReason
764
908
  };
765
909
  }
910
+ resolveSessionResumptionConfig(sessionResumptionOptions) {
911
+ const ticketTtlMs = sessionResumptionOptions?.ticketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
912
+ const maxCachedTickets = sessionResumptionOptions?.maxCachedTickets ?? DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE;
913
+ if (!Number.isFinite(ticketTtlMs) || ticketTtlMs <= 0) {
914
+ throw new Error(
915
+ "Server sessionResumption ticketTtlMs must be a positive number."
916
+ );
917
+ }
918
+ if (!Number.isInteger(maxCachedTickets) || maxCachedTickets <= 0) {
919
+ throw new Error(
920
+ "Server sessionResumption maxCachedTickets must be a positive integer."
921
+ );
922
+ }
923
+ return {
924
+ enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
925
+ ticketTtlMs,
926
+ maxCachedTickets
927
+ };
928
+ }
929
+ pruneExpiredSessionTickets(now) {
930
+ for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
931
+ if (ticketRecord.expiresAt <= now) {
932
+ this.sessionTicketStore.delete(sessionId);
933
+ }
934
+ }
935
+ }
936
+ evictSessionTicketsIfNeeded() {
937
+ while (this.sessionTicketStore.size > this.sessionResumptionConfig.maxCachedTickets) {
938
+ let oldestSessionId = null;
939
+ let oldestIssuedAt = Number.POSITIVE_INFINITY;
940
+ for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
941
+ if (ticketRecord.issuedAt < oldestIssuedAt) {
942
+ oldestIssuedAt = ticketRecord.issuedAt;
943
+ oldestSessionId = sessionId;
944
+ }
945
+ }
946
+ if (!oldestSessionId) {
947
+ break;
948
+ }
949
+ this.sessionTicketStore.delete(oldestSessionId);
950
+ }
951
+ }
952
+ getSessionTicket(sessionId) {
953
+ const now = Date.now();
954
+ this.pruneExpiredSessionTickets(now);
955
+ const ticketRecord = this.sessionTicketStore.get(sessionId);
956
+ if (!ticketRecord) {
957
+ return null;
958
+ }
959
+ if (ticketRecord.expiresAt <= now) {
960
+ this.sessionTicketStore.delete(sessionId);
961
+ return null;
962
+ }
963
+ return ticketRecord;
964
+ }
965
+ issueSessionTicket(socket, baseKey) {
966
+ if (!this.sessionResumptionConfig.enabled) {
967
+ return;
968
+ }
969
+ const now = Date.now();
970
+ this.pruneExpiredSessionTickets(now);
971
+ const sessionId = randomUUID();
972
+ const sessionSecret = deriveSessionTicketSecret(baseKey);
973
+ const expiresAt = now + this.sessionResumptionConfig.ticketTtlMs;
974
+ const ticketRecord = {
975
+ sessionId,
976
+ secret: sessionSecret,
977
+ issuedAt: now,
978
+ expiresAt
979
+ };
980
+ this.sessionTicketStore.set(sessionId, ticketRecord);
981
+ this.evictSessionTicketsIfNeeded();
982
+ const ticketPayload = {
983
+ version: SESSION_TICKET_VERSION,
984
+ sessionId,
985
+ secret: sessionSecret.toString("base64"),
986
+ issuedAt: now,
987
+ expiresAt
988
+ };
989
+ void this.sendOrQueuePayload(socket, {
990
+ event: INTERNAL_SESSION_TICKET_EVENT,
991
+ data: ticketPayload
992
+ }).catch((error) => {
993
+ this.notifyError(
994
+ normalizeToError(error, "Failed to deliver secure session ticket.")
995
+ );
996
+ });
997
+ }
766
998
  createRateLimitBucket(now) {
767
999
  return {
768
1000
  windowStartedAt: now,
@@ -1133,6 +1365,14 @@ var SecureServer = class {
1133
1365
  await this.handleRpcRequest(client, decryptedEnvelope.data);
1134
1366
  return;
1135
1367
  }
1368
+ if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
1369
+ this.notifyError(
1370
+ new Error(
1371
+ `Client ${client.id} attempted to send reserved internal session ticket event.`
1372
+ )
1373
+ );
1374
+ return;
1375
+ }
1136
1376
  const interceptedData = await this.applyMessageMiddleware(
1137
1377
  "incoming",
1138
1378
  client,
@@ -1446,10 +1686,104 @@ var SecureServer = class {
1446
1686
  this.sendRaw(
1447
1687
  socket,
1448
1688
  serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
1689
+ type: "hello",
1690
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
1449
1691
  publicKey: localPublicKey
1450
1692
  })
1451
1693
  );
1452
1694
  }
1695
+ sendResumeAck(socket, payload) {
1696
+ const responsePayload = {
1697
+ type: "resume-ack",
1698
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
1699
+ ok: payload.ok
1700
+ };
1701
+ if (payload.sessionId !== void 0 && payload.sessionId.length > 0) {
1702
+ responsePayload.sessionId = payload.sessionId;
1703
+ }
1704
+ if (payload.serverProof !== void 0 && payload.serverProof.length > 0) {
1705
+ responsePayload.serverProof = payload.serverProof;
1706
+ }
1707
+ if (payload.reason !== void 0 && payload.reason.length > 0) {
1708
+ responsePayload.reason = payload.reason;
1709
+ }
1710
+ this.sendRaw(
1711
+ socket,
1712
+ serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, responsePayload)
1713
+ );
1714
+ }
1715
+ handleResumeHandshake(client, payload) {
1716
+ if (!this.sessionResumptionConfig.enabled) {
1717
+ this.sendResumeAck(client.socket, {
1718
+ ok: false,
1719
+ reason: "Session resumption is disabled."
1720
+ });
1721
+ return;
1722
+ }
1723
+ const ticketRecord = this.getSessionTicket(payload.sessionId);
1724
+ if (!ticketRecord) {
1725
+ this.sendResumeAck(client.socket, {
1726
+ ok: false,
1727
+ reason: "Session ticket is unknown or expired."
1728
+ });
1729
+ return;
1730
+ }
1731
+ try {
1732
+ const clientNonce = decodeBase64ToBuffer(
1733
+ payload.clientNonce,
1734
+ "Handshake resume clientNonce"
1735
+ );
1736
+ if (clientNonce.length !== RESUMPTION_NONCE_LENGTH) {
1737
+ throw new Error(
1738
+ `Handshake resume clientNonce must be ${RESUMPTION_NONCE_LENGTH} bytes.`
1739
+ );
1740
+ }
1741
+ const receivedProof = decodeBase64ToBuffer(
1742
+ payload.clientProof,
1743
+ "Handshake resume clientProof"
1744
+ );
1745
+ const expectedProof = createResumeClientProof(
1746
+ ticketRecord.secret,
1747
+ ticketRecord.sessionId,
1748
+ clientNonce
1749
+ );
1750
+ if (!equalsConstantTime(receivedProof, expectedProof)) {
1751
+ this.sendResumeAck(client.socket, {
1752
+ ok: false,
1753
+ reason: "Session resumption proof validation failed."
1754
+ });
1755
+ return;
1756
+ }
1757
+ this.sessionTicketStore.delete(ticketRecord.sessionId);
1758
+ const resumedKey = deriveResumedEncryptionKey(ticketRecord.secret, clientNonce);
1759
+ const serverProof = createResumeServerProof(
1760
+ resumedKey,
1761
+ ticketRecord.sessionId,
1762
+ clientNonce
1763
+ ).toString("base64");
1764
+ const handshakeState = this.handshakeStateBySocket.get(client.socket);
1765
+ if (!handshakeState) {
1766
+ throw new Error(`Missing handshake state for client ${client.id}.`);
1767
+ }
1768
+ this.sharedSecretBySocket.set(client.socket, resumedKey);
1769
+ this.encryptionKeyBySocket.set(client.socket, resumedKey);
1770
+ handshakeState.isReady = true;
1771
+ this.sendResumeAck(client.socket, {
1772
+ ok: true,
1773
+ sessionId: ticketRecord.sessionId,
1774
+ serverProof
1775
+ });
1776
+ void this.flushQueuedPayloads(client.socket);
1777
+ this.notifyReady(client);
1778
+ this.issueSessionTicket(client.socket, resumedKey);
1779
+ } catch (error) {
1780
+ this.sendResumeAck(client.socket, {
1781
+ ok: false,
1782
+ reason: "Session resumption payload was invalid."
1783
+ });
1784
+ this.notifyError(normalizeToError(error, "Failed to resume secure server session."));
1785
+ }
1786
+ }
1453
1787
  handleInternalHandshake(client, data) {
1454
1788
  try {
1455
1789
  const payload = parseHandshakePayload(data);
@@ -1460,14 +1794,22 @@ var SecureServer = class {
1460
1794
  if (handshakeState.isReady) {
1461
1795
  return;
1462
1796
  }
1797
+ if (payload.type === "resume") {
1798
+ this.handleResumeHandshake(client, payload);
1799
+ return;
1800
+ }
1801
+ if (payload.type === "resume-ack") {
1802
+ throw new Error("SecureServer received unexpected resume-ack handshake payload.");
1803
+ }
1463
1804
  const remotePublicKey = Buffer.from(payload.publicKey, "base64");
1464
1805
  const sharedSecret = handshakeState.ecdh.computeSecret(remotePublicKey);
1465
1806
  const encryptionKey = deriveEncryptionKey(sharedSecret);
1466
1807
  this.sharedSecretBySocket.set(client.socket, sharedSecret);
1467
1808
  this.encryptionKeyBySocket.set(client.socket, encryptionKey);
1468
1809
  handshakeState.isReady = true;
1469
- this.flushQueuedPayloads(client.socket);
1810
+ void this.flushQueuedPayloads(client.socket);
1470
1811
  this.notifyReady(client);
1812
+ this.issueSessionTicket(client.socket, encryptionKey);
1471
1813
  } catch (error) {
1472
1814
  this.notifyError(normalizeToError(error, "Failed to complete server handshake."));
1473
1815
  }
@@ -1681,6 +2023,9 @@ var SecureClient = class {
1681
2023
  this.url = url;
1682
2024
  this.options = options;
1683
2025
  this.reconnectConfig = this.resolveReconnectConfig(this.options.reconnect);
2026
+ this.sessionResumptionConfig = this.resolveSessionResumptionConfig(
2027
+ this.options.sessionResumption
2028
+ );
1684
2029
  if (this.options.autoConnect ?? true) {
1685
2030
  this.connect();
1686
2031
  }
@@ -1689,6 +2034,7 @@ var SecureClient = class {
1689
2034
  options;
1690
2035
  socket = null;
1691
2036
  reconnectConfig;
2037
+ sessionResumptionConfig;
1692
2038
  reconnectAttemptCount = 0;
1693
2039
  reconnectTimer = null;
1694
2040
  isManualDisconnectRequested = false;
@@ -1700,6 +2046,7 @@ var SecureClient = class {
1700
2046
  handshakeState = null;
1701
2047
  pendingPayloadQueue = [];
1702
2048
  pendingRpcRequests = /* @__PURE__ */ new Map();
2049
+ sessionTicket = null;
1703
2050
  get readyState() {
1704
2051
  return this.socket?.readyState ?? null;
1705
2052
  }
@@ -1758,8 +2105,8 @@ var SecureClient = class {
1758
2105
  this.errorHandlers.add(handler);
1759
2106
  return this;
1760
2107
  }
1761
- if (event === INTERNAL_HANDSHAKE_EVENT) {
1762
- throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
2108
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
2109
+ throw new Error(`The event "${event}" is reserved for internal use.`);
1763
2110
  }
1764
2111
  const typedHandler = handler;
1765
2112
  const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
@@ -1790,7 +2137,7 @@ var SecureClient = class {
1790
2137
  this.errorHandlers.delete(handler);
1791
2138
  return this;
1792
2139
  }
1793
- if (event === INTERNAL_HANDSHAKE_EVENT) {
2140
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
1794
2141
  return this;
1795
2142
  }
1796
2143
  const listeners = this.customEventHandlers.get(event);
@@ -1896,6 +2243,24 @@ var SecureClient = class {
1896
2243
  maxAttempts
1897
2244
  };
1898
2245
  }
2246
+ resolveSessionResumptionConfig(sessionResumptionOptions) {
2247
+ if (typeof sessionResumptionOptions === "boolean") {
2248
+ return {
2249
+ enabled: sessionResumptionOptions,
2250
+ maxAcceptedTicketTtlMs: DEFAULT_SESSION_TICKET_TTL_MS
2251
+ };
2252
+ }
2253
+ const maxAcceptedTicketTtlMs = sessionResumptionOptions?.maxAcceptedTicketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
2254
+ if (!Number.isFinite(maxAcceptedTicketTtlMs) || maxAcceptedTicketTtlMs <= 0) {
2255
+ throw new Error(
2256
+ "Client sessionResumption maxAcceptedTicketTtlMs must be a positive number."
2257
+ );
2258
+ }
2259
+ return {
2260
+ enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
2261
+ maxAcceptedTicketTtlMs
2262
+ };
2263
+ }
1899
2264
  scheduleReconnect() {
1900
2265
  if (!this.reconnectConfig.enabled || this.reconnectTimer) {
1901
2266
  return;
@@ -1943,7 +2308,6 @@ var SecureClient = class {
1943
2308
  socket.on("open", () => {
1944
2309
  this.clearReconnectTimer();
1945
2310
  this.reconnectAttemptCount = 0;
1946
- this.sendInternalHandshake();
1947
2311
  this.notifyConnect();
1948
2312
  });
1949
2313
  socket.on("message", (rawData) => {
@@ -1999,6 +2363,10 @@ var SecureClient = class {
1999
2363
  void this.handleRpcRequest(decryptedEnvelope.data);
2000
2364
  return;
2001
2365
  }
2366
+ if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
2367
+ this.handleSessionTicket(decryptedEnvelope.data);
2368
+ return;
2369
+ }
2002
2370
  this.dispatchCustomEvent(decryptedEnvelope.event, decryptedEnvelope.data);
2003
2371
  } catch (error) {
2004
2372
  this.notifyError(normalizeToError(error, "Failed to process incoming client message."));
@@ -2212,11 +2580,45 @@ var SecureClient = class {
2212
2580
  }
2213
2581
  this.pendingRpcRequests.clear();
2214
2582
  }
2583
+ handleSessionTicket(data) {
2584
+ if (!this.sessionResumptionConfig.enabled) {
2585
+ return;
2586
+ }
2587
+ try {
2588
+ const ticketPayload = parseSessionTicketPayload(data);
2589
+ const now = Date.now();
2590
+ if (ticketPayload.expiresAt <= now) {
2591
+ return;
2592
+ }
2593
+ const ticketTtlMs = ticketPayload.expiresAt - ticketPayload.issuedAt;
2594
+ if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
2595
+ throw new Error("Session ticket TTL exceeds client trust policy.");
2596
+ }
2597
+ const sessionSecret = decodeBase64ToBuffer(
2598
+ ticketPayload.secret,
2599
+ "Session ticket secret"
2600
+ );
2601
+ if (sessionSecret.length !== ENCRYPTION_KEY_LENGTH) {
2602
+ throw new Error("Session ticket secret has invalid length.");
2603
+ }
2604
+ this.sessionTicket = {
2605
+ sessionId: ticketPayload.sessionId,
2606
+ secret: sessionSecret,
2607
+ issuedAt: ticketPayload.issuedAt,
2608
+ expiresAt: ticketPayload.expiresAt
2609
+ };
2610
+ } catch (error) {
2611
+ this.notifyError(normalizeToError(error, "Failed to process session ticket payload."));
2612
+ }
2613
+ }
2215
2614
  createClientHandshakeState() {
2216
2615
  const { ecdh, localPublicKey } = createEphemeralHandshakeState();
2217
2616
  return {
2218
2617
  ecdh,
2219
2618
  localPublicKey,
2619
+ clientHelloSent: false,
2620
+ pendingServerPublicKey: null,
2621
+ resumeAttempt: null,
2220
2622
  isReady: false,
2221
2623
  sharedSecret: null,
2222
2624
  encryptionKey: null
@@ -2230,15 +2632,171 @@ var SecureClient = class {
2230
2632
  if (!this.handshakeState) {
2231
2633
  throw new Error("Missing client handshake state.");
2232
2634
  }
2635
+ if (this.handshakeState.clientHelloSent) {
2636
+ return;
2637
+ }
2233
2638
  this.socket.send(
2234
2639
  serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
2640
+ type: "hello",
2641
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
2235
2642
  publicKey: this.handshakeState.localPublicKey
2236
2643
  })
2237
2644
  );
2645
+ this.handshakeState.clientHelloSent = true;
2238
2646
  } catch (error) {
2239
2647
  this.notifyError(normalizeToError(error, "Failed to send client handshake payload."));
2240
2648
  }
2241
2649
  }
2650
+ shouldAttemptSessionResumption() {
2651
+ if (!this.sessionResumptionConfig.enabled) {
2652
+ return false;
2653
+ }
2654
+ const sessionTicket = this.sessionTicket;
2655
+ if (!sessionTicket) {
2656
+ return false;
2657
+ }
2658
+ const now = Date.now();
2659
+ if (sessionTicket.expiresAt <= now) {
2660
+ this.sessionTicket = null;
2661
+ return false;
2662
+ }
2663
+ const ticketTtlMs = sessionTicket.expiresAt - sessionTicket.issuedAt;
2664
+ if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
2665
+ this.sessionTicket = null;
2666
+ return false;
2667
+ }
2668
+ return true;
2669
+ }
2670
+ sendResumeHandshake() {
2671
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
2672
+ return false;
2673
+ }
2674
+ if (!this.handshakeState || !this.sessionTicket) {
2675
+ return false;
2676
+ }
2677
+ if (this.handshakeState.clientHelloSent) {
2678
+ return false;
2679
+ }
2680
+ if (this.handshakeState.resumeAttempt?.status === "pending") {
2681
+ return true;
2682
+ }
2683
+ try {
2684
+ const clientNonce = randomBytes(RESUMPTION_NONCE_LENGTH);
2685
+ const resumedKey = deriveResumedEncryptionKey(this.sessionTicket.secret, clientNonce);
2686
+ const clientProof = createResumeClientProof(
2687
+ this.sessionTicket.secret,
2688
+ this.sessionTicket.sessionId,
2689
+ clientNonce
2690
+ );
2691
+ this.socket.send(
2692
+ serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
2693
+ type: "resume",
2694
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
2695
+ sessionId: this.sessionTicket.sessionId,
2696
+ clientNonce: clientNonce.toString("base64"),
2697
+ clientProof: clientProof.toString("base64")
2698
+ })
2699
+ );
2700
+ this.handshakeState.resumeAttempt = {
2701
+ status: "pending",
2702
+ sessionId: this.sessionTicket.sessionId,
2703
+ clientNonce,
2704
+ resumedKey
2705
+ };
2706
+ return true;
2707
+ } catch (error) {
2708
+ this.notifyError(normalizeToError(error, "Failed to dispatch resume handshake payload."));
2709
+ this.sessionTicket = null;
2710
+ this.handshakeState.resumeAttempt = null;
2711
+ return false;
2712
+ }
2713
+ }
2714
+ completeFullHandshake(serverPublicKey) {
2715
+ if (!this.handshakeState) {
2716
+ throw new Error("Missing client handshake state.");
2717
+ }
2718
+ if (this.handshakeState.isReady) {
2719
+ return;
2720
+ }
2721
+ this.sendInternalHandshake();
2722
+ const remotePublicKey = Buffer.from(serverPublicKey, "base64");
2723
+ const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
2724
+ this.handshakeState.sharedSecret = sharedSecret;
2725
+ this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
2726
+ this.handshakeState.resumeAttempt = null;
2727
+ this.handshakeState.pendingServerPublicKey = null;
2728
+ this.handshakeState.isReady = true;
2729
+ void this.flushPendingPayloadQueue();
2730
+ this.notifyReady();
2731
+ }
2732
+ fallbackToFullHandshake() {
2733
+ if (!this.handshakeState || this.handshakeState.isReady) {
2734
+ return;
2735
+ }
2736
+ if (this.handshakeState.resumeAttempt) {
2737
+ this.handshakeState.resumeAttempt.status = "failed";
2738
+ }
2739
+ const pendingServerPublicKey = this.handshakeState.pendingServerPublicKey;
2740
+ if (pendingServerPublicKey) {
2741
+ this.completeFullHandshake(pendingServerPublicKey);
2742
+ return;
2743
+ }
2744
+ this.sendInternalHandshake();
2745
+ }
2746
+ handleServerHelloHandshake(payload) {
2747
+ if (!this.handshakeState || this.handshakeState.isReady) {
2748
+ return;
2749
+ }
2750
+ this.handshakeState.pendingServerPublicKey = payload.publicKey;
2751
+ if (this.shouldAttemptSessionResumption() && this.sendResumeHandshake()) {
2752
+ return;
2753
+ }
2754
+ this.completeFullHandshake(payload.publicKey);
2755
+ }
2756
+ handleResumeAckHandshake(payload) {
2757
+ if (!this.handshakeState || this.handshakeState.isReady) {
2758
+ return;
2759
+ }
2760
+ const resumeAttempt = this.handshakeState.resumeAttempt;
2761
+ if (!resumeAttempt || resumeAttempt.status !== "pending") {
2762
+ return;
2763
+ }
2764
+ if (!payload.ok) {
2765
+ this.sessionTicket = null;
2766
+ this.fallbackToFullHandshake();
2767
+ return;
2768
+ }
2769
+ if (payload.sessionId !== resumeAttempt.sessionId || !payload.serverProof) {
2770
+ this.sessionTicket = null;
2771
+ this.fallbackToFullHandshake();
2772
+ return;
2773
+ }
2774
+ try {
2775
+ const receivedServerProof = decodeBase64ToBuffer(
2776
+ payload.serverProof,
2777
+ "Handshake resume-ack serverProof"
2778
+ );
2779
+ const expectedServerProof = createResumeServerProof(
2780
+ resumeAttempt.resumedKey,
2781
+ resumeAttempt.sessionId,
2782
+ resumeAttempt.clientNonce
2783
+ );
2784
+ if (!equalsConstantTime(receivedServerProof, expectedServerProof)) {
2785
+ throw new Error("Resume server proof validation failed.");
2786
+ }
2787
+ this.handshakeState.sharedSecret = resumeAttempt.resumedKey;
2788
+ this.handshakeState.encryptionKey = resumeAttempt.resumedKey;
2789
+ this.handshakeState.pendingServerPublicKey = null;
2790
+ resumeAttempt.status = "accepted";
2791
+ this.handshakeState.isReady = true;
2792
+ void this.flushPendingPayloadQueue();
2793
+ this.notifyReady();
2794
+ } catch (error) {
2795
+ this.notifyError(normalizeToError(error, "Failed to verify resume server proof."));
2796
+ this.sessionTicket = null;
2797
+ this.fallbackToFullHandshake();
2798
+ }
2799
+ }
2242
2800
  handleInternalHandshake(data) {
2243
2801
  try {
2244
2802
  const payload = parseHandshakePayload(data);
@@ -2248,13 +2806,15 @@ var SecureClient = class {
2248
2806
  if (this.handshakeState.isReady) {
2249
2807
  return;
2250
2808
  }
2251
- const remotePublicKey = Buffer.from(payload.publicKey, "base64");
2252
- const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
2253
- this.handshakeState.sharedSecret = sharedSecret;
2254
- this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
2255
- this.handshakeState.isReady = true;
2256
- void this.flushPendingPayloadQueue();
2257
- this.notifyReady();
2809
+ if (payload.type === "hello") {
2810
+ this.handleServerHelloHandshake(payload);
2811
+ return;
2812
+ }
2813
+ if (payload.type === "resume-ack") {
2814
+ this.handleResumeAckHandshake(payload);
2815
+ return;
2816
+ }
2817
+ throw new Error("SecureClient received unexpected resume request handshake payload.");
2258
2818
  } catch (error) {
2259
2819
  this.notifyError(normalizeToError(error, "Failed to complete client handshake."));
2260
2820
  }