@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.cjs CHANGED
@@ -13,20 +13,27 @@ var DEFAULT_CLOSE_REASON = "";
13
13
  var POLICY_VIOLATION_CLOSE_CODE = 1008;
14
14
  var POLICY_VIOLATION_CLOSE_REASON = "Connection rejected by middleware.";
15
15
  var INTERNAL_HANDSHAKE_EVENT = "__handshake";
16
+ var INTERNAL_SESSION_TICKET_EVENT = "__session:ticket";
16
17
  var INTERNAL_RPC_REQUEST_EVENT = "__rpc:req";
17
18
  var INTERNAL_RPC_RESPONSE_EVENT = "__rpc:res";
18
19
  var READY_EVENT = "ready";
19
20
  var HANDSHAKE_CURVE = "prime256v1";
21
+ var HANDSHAKE_PROTOCOL_VERSION = 1;
20
22
  var ENCRYPTION_ALGORITHM = "aes-256-gcm";
21
23
  var GCM_IV_LENGTH = 12;
22
24
  var GCM_AUTH_TAG_LENGTH = 16;
23
25
  var ENCRYPTION_KEY_LENGTH = 32;
24
26
  var ENCRYPTED_PACKET_VERSION = 1;
25
27
  var ENCRYPTED_PACKET_PREFIX_LENGTH = 1 + GCM_IV_LENGTH + GCM_AUTH_TAG_LENGTH;
28
+ var SESSION_TICKET_VERSION = 1;
26
29
  var BINARY_PAYLOAD_MARKER = "__afxBinaryPayload";
27
30
  var BINARY_PAYLOAD_VERSION = 1;
28
31
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
29
32
  var DEFAULT_HEARTBEAT_TIMEOUT_MS = 15e3;
33
+ var DEFAULT_SESSION_RESUMPTION_ENABLED = true;
34
+ var DEFAULT_SESSION_TICKET_TTL_MS = 10 * 6e4;
35
+ var DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE = 1e4;
36
+ var RESUMPTION_NONCE_LENGTH = 16;
30
37
  var DEFAULT_RECONNECT_INITIAL_DELAY_MS = 250;
31
38
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 1e4;
32
39
  var DEFAULT_RECONNECT_FACTOR = 2;
@@ -248,7 +255,7 @@ function decodeCloseReason(reason) {
248
255
  return reason.toString("utf8");
249
256
  }
250
257
  function isReservedEmitEvent(event) {
251
- return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === READY_EVENT;
258
+ return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === READY_EVENT;
252
259
  }
253
260
  function isPromiseLike(value) {
254
261
  return typeof value === "object" && value !== null && "then" in value;
@@ -339,16 +346,149 @@ function createEphemeralHandshakeState() {
339
346
  localPublicKey: ecdh.getPublicKey("base64")
340
347
  };
341
348
  }
349
+ function decodeBase64ToBuffer(value, fieldName) {
350
+ if (typeof value !== "string") {
351
+ throw new Error(`${fieldName} must be a base64 string.`);
352
+ }
353
+ const normalizedValue = value.trim();
354
+ if (normalizedValue.length === 0) {
355
+ throw new Error(`${fieldName} must be a non-empty base64 string.`);
356
+ }
357
+ const decodedBuffer = Buffer.from(normalizedValue, "base64");
358
+ if (decodedBuffer.length === 0) {
359
+ throw new Error(`${fieldName} could not be decoded from base64.`);
360
+ }
361
+ const canonicalInput = normalizedValue.replace(/=+$/u, "");
362
+ const canonicalDecoded = decodedBuffer.toString("base64").replace(/=+$/u, "");
363
+ if (canonicalInput !== canonicalDecoded) {
364
+ throw new Error(`${fieldName} is not valid base64 content.`);
365
+ }
366
+ return decodedBuffer;
367
+ }
368
+ function equalsConstantTime(left, right) {
369
+ if (left.length !== right.length) {
370
+ return false;
371
+ }
372
+ return crypto.timingSafeEqual(left, right);
373
+ }
374
+ function createResumeClientProof(sessionSecret, sessionId, clientNonce) {
375
+ return crypto.createHmac("sha256", sessionSecret).update("afx-resume-client-proof:v1").update(sessionId).update(clientNonce).digest();
376
+ }
377
+ function createResumeServerProof(resumedKey, sessionId, clientNonce) {
378
+ return crypto.createHmac("sha256", resumedKey).update("afx-resume-server-proof:v1").update(sessionId).update(clientNonce).digest();
379
+ }
380
+ function deriveSessionTicketSecret(baseKey) {
381
+ return crypto.createHmac("sha256", baseKey).update("afx-session-ticket:v1").digest();
382
+ }
383
+ function deriveResumedEncryptionKey(sessionSecret, clientNonce) {
384
+ const derivedKey = crypto.createHash("sha256").update("afx-resume-encryption-key:v1").update(sessionSecret).update(clientNonce).digest();
385
+ if (derivedKey.length !== ENCRYPTION_KEY_LENGTH) {
386
+ throw new Error("Failed to derive a valid resumed AES-256 key.");
387
+ }
388
+ return derivedKey;
389
+ }
342
390
  function parseHandshakePayload(data) {
343
391
  if (typeof data !== "object" || data === null) {
344
392
  throw new Error("Invalid handshake payload format.");
345
393
  }
346
394
  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.");
395
+ if (typeof payload.type !== "string") {
396
+ if (typeof payload.publicKey === "string" && payload.publicKey.length > 0) {
397
+ return {
398
+ type: "hello",
399
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
400
+ publicKey: payload.publicKey
401
+ };
402
+ }
403
+ throw new Error("Handshake payload must include a valid type.");
404
+ }
405
+ const protocolVersion = payload.protocolVersion === void 0 ? HANDSHAKE_PROTOCOL_VERSION : payload.protocolVersion;
406
+ if (protocolVersion !== HANDSHAKE_PROTOCOL_VERSION) {
407
+ throw new Error(
408
+ `Unsupported handshake protocol version: ${String(protocolVersion)}.`
409
+ );
410
+ }
411
+ if (payload.type === "hello") {
412
+ if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
413
+ throw new Error("Handshake hello payload must include a non-empty public key.");
414
+ }
415
+ return {
416
+ type: "hello",
417
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
418
+ publicKey: payload.publicKey
419
+ };
420
+ }
421
+ if (payload.type === "resume") {
422
+ if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
423
+ throw new Error("Handshake resume payload must include a non-empty sessionId.");
424
+ }
425
+ if (typeof payload.clientNonce !== "string" || payload.clientNonce.length === 0) {
426
+ throw new Error("Handshake resume payload must include a non-empty clientNonce.");
427
+ }
428
+ if (typeof payload.clientProof !== "string" || payload.clientProof.length === 0) {
429
+ throw new Error("Handshake resume payload must include a non-empty clientProof.");
430
+ }
431
+ return {
432
+ type: "resume",
433
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
434
+ sessionId: payload.sessionId.trim(),
435
+ clientNonce: payload.clientNonce,
436
+ clientProof: payload.clientProof
437
+ };
438
+ }
439
+ if (payload.type === "resume-ack") {
440
+ if (typeof payload.ok !== "boolean") {
441
+ throw new Error("Handshake resume-ack payload must include boolean ok.");
442
+ }
443
+ const normalizedPayload = {
444
+ type: "resume-ack",
445
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
446
+ ok: payload.ok
447
+ };
448
+ if (typeof payload.sessionId === "string" && payload.sessionId.trim().length > 0) {
449
+ normalizedPayload.sessionId = payload.sessionId.trim();
450
+ }
451
+ if (typeof payload.serverProof === "string" && payload.serverProof.length > 0) {
452
+ normalizedPayload.serverProof = payload.serverProof;
453
+ }
454
+ if (typeof payload.reason === "string" && payload.reason.trim().length > 0) {
455
+ normalizedPayload.reason = payload.reason.trim();
456
+ }
457
+ return normalizedPayload;
458
+ }
459
+ throw new Error(`Unsupported handshake payload type: ${payload.type}.`);
460
+ }
461
+ function parseSessionTicketPayload(data) {
462
+ if (typeof data !== "object" || data === null) {
463
+ throw new Error("Invalid session ticket payload format.");
464
+ }
465
+ const payload = data;
466
+ if (payload.version !== SESSION_TICKET_VERSION) {
467
+ throw new Error(
468
+ `Unsupported session ticket payload version: ${String(payload.version)}.`
469
+ );
470
+ }
471
+ if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
472
+ throw new Error("Session ticket payload must include a non-empty sessionId.");
473
+ }
474
+ if (typeof payload.secret !== "string" || payload.secret.length === 0) {
475
+ throw new Error("Session ticket payload must include a non-empty secret.");
476
+ }
477
+ if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) {
478
+ throw new Error("Session ticket payload issuedAt must be a finite number.");
479
+ }
480
+ if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) {
481
+ throw new Error("Session ticket payload expiresAt must be a finite number.");
482
+ }
483
+ if (payload.expiresAt <= payload.issuedAt) {
484
+ throw new Error("Session ticket payload expiresAt must be greater than issuedAt.");
349
485
  }
350
486
  return {
351
- publicKey: payload.publicKey
487
+ version: SESSION_TICKET_VERSION,
488
+ sessionId: payload.sessionId.trim(),
489
+ secret: payload.secret,
490
+ issuedAt: payload.issuedAt,
491
+ expiresAt: payload.expiresAt
352
492
  };
353
493
  }
354
494
  function deriveEncryptionKey(sharedSecret) {
@@ -418,6 +558,7 @@ var SecureServer = class {
418
558
  adapter = null;
419
559
  heartbeatConfig;
420
560
  rateLimitConfig;
561
+ sessionResumptionConfig;
421
562
  heartbeatIntervalHandle = null;
422
563
  clientsById = /* @__PURE__ */ new Map();
423
564
  clientIdBySocket = /* @__PURE__ */ new Map();
@@ -439,10 +580,12 @@ var SecureServer = class {
439
580
  clientIpByClientId = /* @__PURE__ */ new Map();
440
581
  rateLimitBucketsByClientId = /* @__PURE__ */ new Map();
441
582
  rateLimitBucketsByIp = /* @__PURE__ */ new Map();
583
+ sessionTicketStore = /* @__PURE__ */ new Map();
442
584
  constructor(options) {
443
- const { heartbeat, rateLimit, adapter, ...socketServerOptions } = options;
585
+ const { heartbeat, rateLimit, sessionResumption, adapter, ...socketServerOptions } = options;
444
586
  this.heartbeatConfig = this.resolveHeartbeatConfig(heartbeat);
445
587
  this.rateLimitConfig = this.resolveRateLimitConfig(rateLimit);
588
+ this.sessionResumptionConfig = this.resolveSessionResumptionConfig(sessionResumption);
446
589
  this.socketServer = new WebSocket.WebSocketServer(socketServerOptions);
447
590
  this.bindSocketServerEvents();
448
591
  this.startHeartbeatLoop();
@@ -528,8 +671,8 @@ var SecureServer = class {
528
671
  this.errorHandlers.add(handler);
529
672
  return this;
530
673
  }
531
- if (event === INTERNAL_HANDSHAKE_EVENT) {
532
- throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
674
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
675
+ throw new Error(`The event "${event}" is reserved for internal use.`);
533
676
  }
534
677
  const typedHandler = handler;
535
678
  const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
@@ -560,7 +703,7 @@ var SecureServer = class {
560
703
  this.errorHandlers.delete(handler);
561
704
  return this;
562
705
  }
563
- if (event === INTERNAL_HANDSHAKE_EVENT) {
706
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
564
707
  return this;
565
708
  }
566
709
  const listeners = this.customEventHandlers.get(event);
@@ -693,6 +836,7 @@ var SecureServer = class {
693
836
  this.rateLimitBucketsByClientId.clear();
694
837
  this.rateLimitBucketsByIp.clear();
695
838
  this.clientIpByClientId.clear();
839
+ this.sessionTicketStore.clear();
696
840
  this.socketServer.close();
697
841
  } catch (error) {
698
842
  this.notifyError(normalizeToError(error, "Failed to close server."));
@@ -769,6 +913,94 @@ var SecureServer = class {
769
913
  disconnectReason
770
914
  };
771
915
  }
916
+ resolveSessionResumptionConfig(sessionResumptionOptions) {
917
+ const ticketTtlMs = sessionResumptionOptions?.ticketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
918
+ const maxCachedTickets = sessionResumptionOptions?.maxCachedTickets ?? DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE;
919
+ if (!Number.isFinite(ticketTtlMs) || ticketTtlMs <= 0) {
920
+ throw new Error(
921
+ "Server sessionResumption ticketTtlMs must be a positive number."
922
+ );
923
+ }
924
+ if (!Number.isInteger(maxCachedTickets) || maxCachedTickets <= 0) {
925
+ throw new Error(
926
+ "Server sessionResumption maxCachedTickets must be a positive integer."
927
+ );
928
+ }
929
+ return {
930
+ enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
931
+ ticketTtlMs,
932
+ maxCachedTickets
933
+ };
934
+ }
935
+ pruneExpiredSessionTickets(now) {
936
+ for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
937
+ if (ticketRecord.expiresAt <= now) {
938
+ this.sessionTicketStore.delete(sessionId);
939
+ }
940
+ }
941
+ }
942
+ evictSessionTicketsIfNeeded() {
943
+ while (this.sessionTicketStore.size > this.sessionResumptionConfig.maxCachedTickets) {
944
+ let oldestSessionId = null;
945
+ let oldestIssuedAt = Number.POSITIVE_INFINITY;
946
+ for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
947
+ if (ticketRecord.issuedAt < oldestIssuedAt) {
948
+ oldestIssuedAt = ticketRecord.issuedAt;
949
+ oldestSessionId = sessionId;
950
+ }
951
+ }
952
+ if (!oldestSessionId) {
953
+ break;
954
+ }
955
+ this.sessionTicketStore.delete(oldestSessionId);
956
+ }
957
+ }
958
+ getSessionTicket(sessionId) {
959
+ const now = Date.now();
960
+ this.pruneExpiredSessionTickets(now);
961
+ const ticketRecord = this.sessionTicketStore.get(sessionId);
962
+ if (!ticketRecord) {
963
+ return null;
964
+ }
965
+ if (ticketRecord.expiresAt <= now) {
966
+ this.sessionTicketStore.delete(sessionId);
967
+ return null;
968
+ }
969
+ return ticketRecord;
970
+ }
971
+ issueSessionTicket(socket, baseKey) {
972
+ if (!this.sessionResumptionConfig.enabled) {
973
+ return;
974
+ }
975
+ const now = Date.now();
976
+ this.pruneExpiredSessionTickets(now);
977
+ const sessionId = crypto.randomUUID();
978
+ const sessionSecret = deriveSessionTicketSecret(baseKey);
979
+ const expiresAt = now + this.sessionResumptionConfig.ticketTtlMs;
980
+ const ticketRecord = {
981
+ sessionId,
982
+ secret: sessionSecret,
983
+ issuedAt: now,
984
+ expiresAt
985
+ };
986
+ this.sessionTicketStore.set(sessionId, ticketRecord);
987
+ this.evictSessionTicketsIfNeeded();
988
+ const ticketPayload = {
989
+ version: SESSION_TICKET_VERSION,
990
+ sessionId,
991
+ secret: sessionSecret.toString("base64"),
992
+ issuedAt: now,
993
+ expiresAt
994
+ };
995
+ void this.sendOrQueuePayload(socket, {
996
+ event: INTERNAL_SESSION_TICKET_EVENT,
997
+ data: ticketPayload
998
+ }).catch((error) => {
999
+ this.notifyError(
1000
+ normalizeToError(error, "Failed to deliver secure session ticket.")
1001
+ );
1002
+ });
1003
+ }
772
1004
  createRateLimitBucket(now) {
773
1005
  return {
774
1006
  windowStartedAt: now,
@@ -1139,6 +1371,14 @@ var SecureServer = class {
1139
1371
  await this.handleRpcRequest(client, decryptedEnvelope.data);
1140
1372
  return;
1141
1373
  }
1374
+ if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
1375
+ this.notifyError(
1376
+ new Error(
1377
+ `Client ${client.id} attempted to send reserved internal session ticket event.`
1378
+ )
1379
+ );
1380
+ return;
1381
+ }
1142
1382
  const interceptedData = await this.applyMessageMiddleware(
1143
1383
  "incoming",
1144
1384
  client,
@@ -1452,10 +1692,104 @@ var SecureServer = class {
1452
1692
  this.sendRaw(
1453
1693
  socket,
1454
1694
  serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
1695
+ type: "hello",
1696
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
1455
1697
  publicKey: localPublicKey
1456
1698
  })
1457
1699
  );
1458
1700
  }
1701
+ sendResumeAck(socket, payload) {
1702
+ const responsePayload = {
1703
+ type: "resume-ack",
1704
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
1705
+ ok: payload.ok
1706
+ };
1707
+ if (payload.sessionId !== void 0 && payload.sessionId.length > 0) {
1708
+ responsePayload.sessionId = payload.sessionId;
1709
+ }
1710
+ if (payload.serverProof !== void 0 && payload.serverProof.length > 0) {
1711
+ responsePayload.serverProof = payload.serverProof;
1712
+ }
1713
+ if (payload.reason !== void 0 && payload.reason.length > 0) {
1714
+ responsePayload.reason = payload.reason;
1715
+ }
1716
+ this.sendRaw(
1717
+ socket,
1718
+ serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, responsePayload)
1719
+ );
1720
+ }
1721
+ handleResumeHandshake(client, payload) {
1722
+ if (!this.sessionResumptionConfig.enabled) {
1723
+ this.sendResumeAck(client.socket, {
1724
+ ok: false,
1725
+ reason: "Session resumption is disabled."
1726
+ });
1727
+ return;
1728
+ }
1729
+ const ticketRecord = this.getSessionTicket(payload.sessionId);
1730
+ if (!ticketRecord) {
1731
+ this.sendResumeAck(client.socket, {
1732
+ ok: false,
1733
+ reason: "Session ticket is unknown or expired."
1734
+ });
1735
+ return;
1736
+ }
1737
+ try {
1738
+ const clientNonce = decodeBase64ToBuffer(
1739
+ payload.clientNonce,
1740
+ "Handshake resume clientNonce"
1741
+ );
1742
+ if (clientNonce.length !== RESUMPTION_NONCE_LENGTH) {
1743
+ throw new Error(
1744
+ `Handshake resume clientNonce must be ${RESUMPTION_NONCE_LENGTH} bytes.`
1745
+ );
1746
+ }
1747
+ const receivedProof = decodeBase64ToBuffer(
1748
+ payload.clientProof,
1749
+ "Handshake resume clientProof"
1750
+ );
1751
+ const expectedProof = createResumeClientProof(
1752
+ ticketRecord.secret,
1753
+ ticketRecord.sessionId,
1754
+ clientNonce
1755
+ );
1756
+ if (!equalsConstantTime(receivedProof, expectedProof)) {
1757
+ this.sendResumeAck(client.socket, {
1758
+ ok: false,
1759
+ reason: "Session resumption proof validation failed."
1760
+ });
1761
+ return;
1762
+ }
1763
+ this.sessionTicketStore.delete(ticketRecord.sessionId);
1764
+ const resumedKey = deriveResumedEncryptionKey(ticketRecord.secret, clientNonce);
1765
+ const serverProof = createResumeServerProof(
1766
+ resumedKey,
1767
+ ticketRecord.sessionId,
1768
+ clientNonce
1769
+ ).toString("base64");
1770
+ const handshakeState = this.handshakeStateBySocket.get(client.socket);
1771
+ if (!handshakeState) {
1772
+ throw new Error(`Missing handshake state for client ${client.id}.`);
1773
+ }
1774
+ this.sharedSecretBySocket.set(client.socket, resumedKey);
1775
+ this.encryptionKeyBySocket.set(client.socket, resumedKey);
1776
+ handshakeState.isReady = true;
1777
+ this.sendResumeAck(client.socket, {
1778
+ ok: true,
1779
+ sessionId: ticketRecord.sessionId,
1780
+ serverProof
1781
+ });
1782
+ void this.flushQueuedPayloads(client.socket);
1783
+ this.notifyReady(client);
1784
+ this.issueSessionTicket(client.socket, resumedKey);
1785
+ } catch (error) {
1786
+ this.sendResumeAck(client.socket, {
1787
+ ok: false,
1788
+ reason: "Session resumption payload was invalid."
1789
+ });
1790
+ this.notifyError(normalizeToError(error, "Failed to resume secure server session."));
1791
+ }
1792
+ }
1459
1793
  handleInternalHandshake(client, data) {
1460
1794
  try {
1461
1795
  const payload = parseHandshakePayload(data);
@@ -1466,14 +1800,22 @@ var SecureServer = class {
1466
1800
  if (handshakeState.isReady) {
1467
1801
  return;
1468
1802
  }
1803
+ if (payload.type === "resume") {
1804
+ this.handleResumeHandshake(client, payload);
1805
+ return;
1806
+ }
1807
+ if (payload.type === "resume-ack") {
1808
+ throw new Error("SecureServer received unexpected resume-ack handshake payload.");
1809
+ }
1469
1810
  const remotePublicKey = Buffer.from(payload.publicKey, "base64");
1470
1811
  const sharedSecret = handshakeState.ecdh.computeSecret(remotePublicKey);
1471
1812
  const encryptionKey = deriveEncryptionKey(sharedSecret);
1472
1813
  this.sharedSecretBySocket.set(client.socket, sharedSecret);
1473
1814
  this.encryptionKeyBySocket.set(client.socket, encryptionKey);
1474
1815
  handshakeState.isReady = true;
1475
- this.flushQueuedPayloads(client.socket);
1816
+ void this.flushQueuedPayloads(client.socket);
1476
1817
  this.notifyReady(client);
1818
+ this.issueSessionTicket(client.socket, encryptionKey);
1477
1819
  } catch (error) {
1478
1820
  this.notifyError(normalizeToError(error, "Failed to complete server handshake."));
1479
1821
  }
@@ -1687,6 +2029,9 @@ var SecureClient = class {
1687
2029
  this.url = url;
1688
2030
  this.options = options;
1689
2031
  this.reconnectConfig = this.resolveReconnectConfig(this.options.reconnect);
2032
+ this.sessionResumptionConfig = this.resolveSessionResumptionConfig(
2033
+ this.options.sessionResumption
2034
+ );
1690
2035
  if (this.options.autoConnect ?? true) {
1691
2036
  this.connect();
1692
2037
  }
@@ -1695,6 +2040,7 @@ var SecureClient = class {
1695
2040
  options;
1696
2041
  socket = null;
1697
2042
  reconnectConfig;
2043
+ sessionResumptionConfig;
1698
2044
  reconnectAttemptCount = 0;
1699
2045
  reconnectTimer = null;
1700
2046
  isManualDisconnectRequested = false;
@@ -1706,6 +2052,7 @@ var SecureClient = class {
1706
2052
  handshakeState = null;
1707
2053
  pendingPayloadQueue = [];
1708
2054
  pendingRpcRequests = /* @__PURE__ */ new Map();
2055
+ sessionTicket = null;
1709
2056
  get readyState() {
1710
2057
  return this.socket?.readyState ?? null;
1711
2058
  }
@@ -1764,8 +2111,8 @@ var SecureClient = class {
1764
2111
  this.errorHandlers.add(handler);
1765
2112
  return this;
1766
2113
  }
1767
- if (event === INTERNAL_HANDSHAKE_EVENT) {
1768
- throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
2114
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
2115
+ throw new Error(`The event "${event}" is reserved for internal use.`);
1769
2116
  }
1770
2117
  const typedHandler = handler;
1771
2118
  const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
@@ -1796,7 +2143,7 @@ var SecureClient = class {
1796
2143
  this.errorHandlers.delete(handler);
1797
2144
  return this;
1798
2145
  }
1799
- if (event === INTERNAL_HANDSHAKE_EVENT) {
2146
+ if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
1800
2147
  return this;
1801
2148
  }
1802
2149
  const listeners = this.customEventHandlers.get(event);
@@ -1902,6 +2249,24 @@ var SecureClient = class {
1902
2249
  maxAttempts
1903
2250
  };
1904
2251
  }
2252
+ resolveSessionResumptionConfig(sessionResumptionOptions) {
2253
+ if (typeof sessionResumptionOptions === "boolean") {
2254
+ return {
2255
+ enabled: sessionResumptionOptions,
2256
+ maxAcceptedTicketTtlMs: DEFAULT_SESSION_TICKET_TTL_MS
2257
+ };
2258
+ }
2259
+ const maxAcceptedTicketTtlMs = sessionResumptionOptions?.maxAcceptedTicketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
2260
+ if (!Number.isFinite(maxAcceptedTicketTtlMs) || maxAcceptedTicketTtlMs <= 0) {
2261
+ throw new Error(
2262
+ "Client sessionResumption maxAcceptedTicketTtlMs must be a positive number."
2263
+ );
2264
+ }
2265
+ return {
2266
+ enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
2267
+ maxAcceptedTicketTtlMs
2268
+ };
2269
+ }
1905
2270
  scheduleReconnect() {
1906
2271
  if (!this.reconnectConfig.enabled || this.reconnectTimer) {
1907
2272
  return;
@@ -1949,7 +2314,6 @@ var SecureClient = class {
1949
2314
  socket.on("open", () => {
1950
2315
  this.clearReconnectTimer();
1951
2316
  this.reconnectAttemptCount = 0;
1952
- this.sendInternalHandshake();
1953
2317
  this.notifyConnect();
1954
2318
  });
1955
2319
  socket.on("message", (rawData) => {
@@ -2005,6 +2369,10 @@ var SecureClient = class {
2005
2369
  void this.handleRpcRequest(decryptedEnvelope.data);
2006
2370
  return;
2007
2371
  }
2372
+ if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
2373
+ this.handleSessionTicket(decryptedEnvelope.data);
2374
+ return;
2375
+ }
2008
2376
  this.dispatchCustomEvent(decryptedEnvelope.event, decryptedEnvelope.data);
2009
2377
  } catch (error) {
2010
2378
  this.notifyError(normalizeToError(error, "Failed to process incoming client message."));
@@ -2218,11 +2586,45 @@ var SecureClient = class {
2218
2586
  }
2219
2587
  this.pendingRpcRequests.clear();
2220
2588
  }
2589
+ handleSessionTicket(data) {
2590
+ if (!this.sessionResumptionConfig.enabled) {
2591
+ return;
2592
+ }
2593
+ try {
2594
+ const ticketPayload = parseSessionTicketPayload(data);
2595
+ const now = Date.now();
2596
+ if (ticketPayload.expiresAt <= now) {
2597
+ return;
2598
+ }
2599
+ const ticketTtlMs = ticketPayload.expiresAt - ticketPayload.issuedAt;
2600
+ if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
2601
+ throw new Error("Session ticket TTL exceeds client trust policy.");
2602
+ }
2603
+ const sessionSecret = decodeBase64ToBuffer(
2604
+ ticketPayload.secret,
2605
+ "Session ticket secret"
2606
+ );
2607
+ if (sessionSecret.length !== ENCRYPTION_KEY_LENGTH) {
2608
+ throw new Error("Session ticket secret has invalid length.");
2609
+ }
2610
+ this.sessionTicket = {
2611
+ sessionId: ticketPayload.sessionId,
2612
+ secret: sessionSecret,
2613
+ issuedAt: ticketPayload.issuedAt,
2614
+ expiresAt: ticketPayload.expiresAt
2615
+ };
2616
+ } catch (error) {
2617
+ this.notifyError(normalizeToError(error, "Failed to process session ticket payload."));
2618
+ }
2619
+ }
2221
2620
  createClientHandshakeState() {
2222
2621
  const { ecdh, localPublicKey } = createEphemeralHandshakeState();
2223
2622
  return {
2224
2623
  ecdh,
2225
2624
  localPublicKey,
2625
+ clientHelloSent: false,
2626
+ pendingServerPublicKey: null,
2627
+ resumeAttempt: null,
2226
2628
  isReady: false,
2227
2629
  sharedSecret: null,
2228
2630
  encryptionKey: null
@@ -2236,15 +2638,171 @@ var SecureClient = class {
2236
2638
  if (!this.handshakeState) {
2237
2639
  throw new Error("Missing client handshake state.");
2238
2640
  }
2641
+ if (this.handshakeState.clientHelloSent) {
2642
+ return;
2643
+ }
2239
2644
  this.socket.send(
2240
2645
  serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
2646
+ type: "hello",
2647
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
2241
2648
  publicKey: this.handshakeState.localPublicKey
2242
2649
  })
2243
2650
  );
2651
+ this.handshakeState.clientHelloSent = true;
2244
2652
  } catch (error) {
2245
2653
  this.notifyError(normalizeToError(error, "Failed to send client handshake payload."));
2246
2654
  }
2247
2655
  }
2656
+ shouldAttemptSessionResumption() {
2657
+ if (!this.sessionResumptionConfig.enabled) {
2658
+ return false;
2659
+ }
2660
+ const sessionTicket = this.sessionTicket;
2661
+ if (!sessionTicket) {
2662
+ return false;
2663
+ }
2664
+ const now = Date.now();
2665
+ if (sessionTicket.expiresAt <= now) {
2666
+ this.sessionTicket = null;
2667
+ return false;
2668
+ }
2669
+ const ticketTtlMs = sessionTicket.expiresAt - sessionTicket.issuedAt;
2670
+ if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
2671
+ this.sessionTicket = null;
2672
+ return false;
2673
+ }
2674
+ return true;
2675
+ }
2676
+ sendResumeHandshake() {
2677
+ if (!this.socket || this.socket.readyState !== WebSocket__default.default.OPEN) {
2678
+ return false;
2679
+ }
2680
+ if (!this.handshakeState || !this.sessionTicket) {
2681
+ return false;
2682
+ }
2683
+ if (this.handshakeState.clientHelloSent) {
2684
+ return false;
2685
+ }
2686
+ if (this.handshakeState.resumeAttempt?.status === "pending") {
2687
+ return true;
2688
+ }
2689
+ try {
2690
+ const clientNonce = crypto.randomBytes(RESUMPTION_NONCE_LENGTH);
2691
+ const resumedKey = deriveResumedEncryptionKey(this.sessionTicket.secret, clientNonce);
2692
+ const clientProof = createResumeClientProof(
2693
+ this.sessionTicket.secret,
2694
+ this.sessionTicket.sessionId,
2695
+ clientNonce
2696
+ );
2697
+ this.socket.send(
2698
+ serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
2699
+ type: "resume",
2700
+ protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
2701
+ sessionId: this.sessionTicket.sessionId,
2702
+ clientNonce: clientNonce.toString("base64"),
2703
+ clientProof: clientProof.toString("base64")
2704
+ })
2705
+ );
2706
+ this.handshakeState.resumeAttempt = {
2707
+ status: "pending",
2708
+ sessionId: this.sessionTicket.sessionId,
2709
+ clientNonce,
2710
+ resumedKey
2711
+ };
2712
+ return true;
2713
+ } catch (error) {
2714
+ this.notifyError(normalizeToError(error, "Failed to dispatch resume handshake payload."));
2715
+ this.sessionTicket = null;
2716
+ this.handshakeState.resumeAttempt = null;
2717
+ return false;
2718
+ }
2719
+ }
2720
+ completeFullHandshake(serverPublicKey) {
2721
+ if (!this.handshakeState) {
2722
+ throw new Error("Missing client handshake state.");
2723
+ }
2724
+ if (this.handshakeState.isReady) {
2725
+ return;
2726
+ }
2727
+ this.sendInternalHandshake();
2728
+ const remotePublicKey = Buffer.from(serverPublicKey, "base64");
2729
+ const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
2730
+ this.handshakeState.sharedSecret = sharedSecret;
2731
+ this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
2732
+ this.handshakeState.resumeAttempt = null;
2733
+ this.handshakeState.pendingServerPublicKey = null;
2734
+ this.handshakeState.isReady = true;
2735
+ void this.flushPendingPayloadQueue();
2736
+ this.notifyReady();
2737
+ }
2738
+ fallbackToFullHandshake() {
2739
+ if (!this.handshakeState || this.handshakeState.isReady) {
2740
+ return;
2741
+ }
2742
+ if (this.handshakeState.resumeAttempt) {
2743
+ this.handshakeState.resumeAttempt.status = "failed";
2744
+ }
2745
+ const pendingServerPublicKey = this.handshakeState.pendingServerPublicKey;
2746
+ if (pendingServerPublicKey) {
2747
+ this.completeFullHandshake(pendingServerPublicKey);
2748
+ return;
2749
+ }
2750
+ this.sendInternalHandshake();
2751
+ }
2752
+ handleServerHelloHandshake(payload) {
2753
+ if (!this.handshakeState || this.handshakeState.isReady) {
2754
+ return;
2755
+ }
2756
+ this.handshakeState.pendingServerPublicKey = payload.publicKey;
2757
+ if (this.shouldAttemptSessionResumption() && this.sendResumeHandshake()) {
2758
+ return;
2759
+ }
2760
+ this.completeFullHandshake(payload.publicKey);
2761
+ }
2762
+ handleResumeAckHandshake(payload) {
2763
+ if (!this.handshakeState || this.handshakeState.isReady) {
2764
+ return;
2765
+ }
2766
+ const resumeAttempt = this.handshakeState.resumeAttempt;
2767
+ if (!resumeAttempt || resumeAttempt.status !== "pending") {
2768
+ return;
2769
+ }
2770
+ if (!payload.ok) {
2771
+ this.sessionTicket = null;
2772
+ this.fallbackToFullHandshake();
2773
+ return;
2774
+ }
2775
+ if (payload.sessionId !== resumeAttempt.sessionId || !payload.serverProof) {
2776
+ this.sessionTicket = null;
2777
+ this.fallbackToFullHandshake();
2778
+ return;
2779
+ }
2780
+ try {
2781
+ const receivedServerProof = decodeBase64ToBuffer(
2782
+ payload.serverProof,
2783
+ "Handshake resume-ack serverProof"
2784
+ );
2785
+ const expectedServerProof = createResumeServerProof(
2786
+ resumeAttempt.resumedKey,
2787
+ resumeAttempt.sessionId,
2788
+ resumeAttempt.clientNonce
2789
+ );
2790
+ if (!equalsConstantTime(receivedServerProof, expectedServerProof)) {
2791
+ throw new Error("Resume server proof validation failed.");
2792
+ }
2793
+ this.handshakeState.sharedSecret = resumeAttempt.resumedKey;
2794
+ this.handshakeState.encryptionKey = resumeAttempt.resumedKey;
2795
+ this.handshakeState.pendingServerPublicKey = null;
2796
+ resumeAttempt.status = "accepted";
2797
+ this.handshakeState.isReady = true;
2798
+ void this.flushPendingPayloadQueue();
2799
+ this.notifyReady();
2800
+ } catch (error) {
2801
+ this.notifyError(normalizeToError(error, "Failed to verify resume server proof."));
2802
+ this.sessionTicket = null;
2803
+ this.fallbackToFullHandshake();
2804
+ }
2805
+ }
2248
2806
  handleInternalHandshake(data) {
2249
2807
  try {
2250
2808
  const payload = parseHandshakePayload(data);
@@ -2254,13 +2812,15 @@ var SecureClient = class {
2254
2812
  if (this.handshakeState.isReady) {
2255
2813
  return;
2256
2814
  }
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();
2815
+ if (payload.type === "hello") {
2816
+ this.handleServerHelloHandshake(payload);
2817
+ return;
2818
+ }
2819
+ if (payload.type === "resume-ack") {
2820
+ this.handleResumeAckHandshake(payload);
2821
+ return;
2822
+ }
2823
+ throw new Error("SecureClient received unexpected resume request handshake payload.");
2264
2824
  } catch (error) {
2265
2825
  this.notifyError(normalizeToError(error, "Failed to complete client handshake."));
2266
2826
  }