iosignal 6.1.1 → 7.0.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.
@@ -9,12 +9,13 @@ import require$$7 from 'url';
9
9
  import require$$0 from 'zlib';
10
10
  import require$$0$1 from 'buffer';
11
11
  import require$$2 from 'util';
12
+ import { performance } from 'node:perf_hooks';
12
13
  import { networkInterfaces } from 'os';
13
14
  import fs, { readFileSync } from 'fs';
14
15
  import { memoryUsage } from 'process';
15
16
  import path from 'path';
16
17
 
17
- var version$1 = "6.1.1";
18
+ var version$1 = "7.0.0";
18
19
  var pkg = {
19
20
  version: version$1};
20
21
 
@@ -7369,6 +7370,13 @@ let serverOption = {
7369
7370
  limitCounter: 1000
7370
7371
  },
7371
7372
 
7373
+ // Applied to each new Server instance; packet layouts remain unchanged.
7374
+ security: {
7375
+ maxClockSkewMs: 60000,
7376
+ authChallengeMaxAgeMs: 60000,
7377
+ maxReplayEntries: 65536
7378
+ },
7379
+
7372
7380
  auth: {
7373
7381
  delay_auth_fail: 600
7374
7382
  },
@@ -7392,6 +7400,13 @@ class RemoteCore {
7392
7400
  socket.openTime = Date.now();
7393
7401
 
7394
7402
  this.boho = new it();
7403
+ this.security = manager.server.security;
7404
+ this._challengeIssuedAt = null;
7405
+ this._challengeUsed = false;
7406
+ this._replayBuckets = new Map();
7407
+ this._replayEntries = 0;
7408
+ this._replayExpiredBefore = 0;
7409
+ this.securityFailure = null;
7395
7410
  this.encMode = ENC_MODE.AUTO;
7396
7411
  this.channels = new Set(); // subscribed tags
7397
7412
  this.retain_signal = new Map();
@@ -7480,15 +7495,92 @@ class RemoteCore {
7480
7495
  return true;
7481
7496
  }
7482
7497
 
7498
+ issueAuthChallenge() {
7499
+ const packet = this.boho.server_time_nonce();
7500
+ this._challengeIssuedAt = performance.now();
7501
+ return packet;
7502
+ }
7503
+
7504
+ securityMismatch(reason) {
7505
+ if (this._closing || this.state === STATE.CLOSED) return false;
7506
+ this.securityFailure = reason;
7507
+ this.boho.clearAuth();
7508
+ this.isAdmin = false;
7509
+ this._replayBuckets.clear();
7510
+ this._replayEntries = 0;
7511
+ this.close(true);
7512
+ this.manager.attackLogger?.log(`SECURITY #${this.ssid} reason:${reason}`);
7513
+ this.manager.server.emit('security:mismatch', this, reason);
7514
+ return false;
7515
+ }
7516
+
7517
+ checkAuthChallenge() {
7518
+ if (this._closing || this.state === STATE.CLOSED) return false;
7519
+ if (this._challengeUsed) return this.securityMismatch('AUTH_CHALLENGE_REUSED');
7520
+ if (this._challengeIssuedAt === null ||
7521
+ performance.now() - this._challengeIssuedAt > this.security.authChallengeMaxAgeMs) {
7522
+ return this.securityMismatch('AUTH_CHALLENGE_EXPIRED');
7523
+ }
7524
+ return true;
7525
+ }
7526
+
7527
+ // Called only after Boho has authenticated the packet's clock and payload.
7528
+ acceptEncryptedClock(message, receivedAtMs) {
7529
+ const seconds = message.readUInt32LE(5);
7530
+ const milliseconds = message.readUInt16LE(9);
7531
+ const counter = message.readUInt16LE(11);
7532
+ const packetTime = seconds * 1000 + milliseconds;
7533
+ const earliest = receivedAtMs - this.security.maxClockSkewMs;
7534
+ if (milliseconds >= 1000 || Math.abs(receivedAtMs - packetTime) > this.security.maxClockSkewMs ||
7535
+ packetTime < this._replayExpiredBefore) {
7536
+ return this.securityMismatch('TIME_MISMATCH');
7537
+ }
7538
+ // Never reopen an expired timestamp range after the server clock moves back.
7539
+ this._replayExpiredBefore = Math.max(this._replayExpiredBefore, earliest);
7540
+ for (const [second, values] of this._replayBuckets) {
7541
+ if ((second + 1) * 1000 <= this._replayExpiredBefore) {
7542
+ this._replayEntries -= values.size;
7543
+ this._replayBuckets.delete(second);
7544
+ }
7545
+ }
7546
+ let bucket = this._replayBuckets.get(seconds);
7547
+ const value = milliseconds * 65536 + counter;
7548
+ if (bucket?.has(value)) return this.securityMismatch('REPLAY_DETECTED');
7549
+ // Fail closed instead of evicting still-valid replay evidence under load.
7550
+ if (this._replayEntries >= this.security.maxReplayEntries) {
7551
+ return this.securityMismatch('REPLAY_CAPACITY_EXCEEDED');
7552
+ }
7553
+ if (!bucket) this._replayBuckets.set(seconds, bucket = new Set());
7554
+ bucket.add(value);
7555
+ this._replayEntries++;
7556
+ return true;
7557
+ }
7558
+
7559
+ decryptSessionPacket(message, receivedAtMs) {
7560
+ let decoded;
7561
+ try {
7562
+ decoded = this.boho.decrypt_488(message);
7563
+ } catch {
7564
+ this.securityMismatch('INVALID_ENCRYPTED_PACKET');
7565
+ return;
7566
+ }
7567
+ if (!decoded) {
7568
+ this.securityMismatch('INVALID_ENCRYPTED_PACKET');
7569
+ return;
7570
+ }
7571
+ if (this.acceptEncryptedClock(message, receivedAtMs)) return decoded;
7572
+ }
7573
+
7483
7574
  onTimeDelayMessage(message, isBinary = true) {
7575
+ const receivedAtMs = Date.now();
7484
7576
  setTimeout(() => {
7485
- this.onSocketMessage(message, isBinary);
7577
+ this.onSocketMessage(message, isBinary, receivedAtMs);
7486
7578
  }
7487
7579
  , serverOption.debug.delay);
7488
7580
  }
7489
7581
 
7490
7582
  // CongSocket or WebSocket
7491
- onSocketMessage(message, isBinary = true) {
7583
+ onSocketMessage(message, isBinary = true, receivedAtMs = Date.now()) {
7492
7584
 
7493
7585
  if (this._closing || this.state === STATE.CLOSED) return;
7494
7586
 
@@ -7504,12 +7596,7 @@ class RemoteCore {
7504
7596
 
7505
7597
  if (msgType === it.BohoMsg.ENC_488) {
7506
7598
 
7507
- try {
7508
- decoded = this.boho.decrypt_488(message);
7509
- } catch (err) {
7510
- console.log('-- E488 DEC_FAIL', err);
7511
- return
7512
- }
7599
+ decoded = this.decryptSessionPacket(message, receivedAtMs);
7513
7600
 
7514
7601
  if (decoded) {
7515
7602
  msgType = decoded[0];
@@ -7519,12 +7606,7 @@ class RemoteCore {
7519
7606
  }
7520
7607
 
7521
7608
  } else if (msgType === it.BohoMsg.ENC_E2E) { // symetric E2EE Signal
7522
- try {
7523
- decoded = this.boho.decrypt_488(message);
7524
- } catch (err) {
7525
- // console.log('-- E2E DEC_FAIL', err)
7526
- return
7527
- }
7609
+ decoded = this.decryptSessionPacket(message, receivedAtMs);
7528
7610
  // console.log('e2e unpack:', decoded )
7529
7611
  if (decoded) {
7530
7612
  msgType = decoded[0];
@@ -7669,13 +7751,17 @@ class RemoteCore {
7669
7751
  // client's auth requst
7670
7752
  case it.BohoMsg.AUTH_REQ:
7671
7753
  if (!this.manager.authManager) return
7672
- // Do not start overlapping verification on the same connection.
7673
- if (this.state === STATE.AUTH_REQ) return;
7754
+ // A second request while verification is pending must not restart auth.
7755
+ if (this.state === STATE.AUTH_REQ) {
7756
+ this.securityMismatch('AUTH_IN_PROGRESS');
7757
+ return;
7758
+ }
7674
7759
  if (![STATE.SERVER_READY, STATE.AUTH_RES, STATE.AUTH_FAIL, STATE.CID_RES].includes(this.state)) {
7675
7760
  console.log('protocol error. must called auth_req after server_ready');
7676
7761
  this.close();
7677
7762
  return;
7678
7763
  }
7764
+ if (!this.checkAuthChallenge()) return;
7679
7765
  this.setState(STATE.AUTH_REQ);
7680
7766
  //async
7681
7767
  this.manager.authManager.verify_auth_req(message, this)
@@ -8317,7 +8403,7 @@ class Manager {
8317
8403
  socket.isAlive = true;
8318
8404
  let remote = new Remote(socket, req, this);
8319
8405
  this.remotes.add(remote);
8320
- remote.send( remote.boho.server_time_nonce() );
8406
+ remote.send(remote.issueAuthChallenge());
8321
8407
  remote.send( Buffer.from([ IOMsg.SERVER_READY]) );
8322
8408
  remote.setState(STATE.SERVER_READY);
8323
8409
  this.lastSSID = remote.ssid;
@@ -8699,6 +8785,13 @@ class Server extends require$$0$3 {
8699
8785
 
8700
8786
  constructor(options, authManager) {
8701
8787
  super();
8788
+ const security = { ...serverOption.security, ...options.security };
8789
+ for (const name of ['maxClockSkewMs', 'authChallengeMaxAgeMs', 'maxReplayEntries']) {
8790
+ if (!Number.isSafeInteger(security[name]) || security[name] <= 0) {
8791
+ throw new RangeError(`security.${name} must be a positive safe integer`);
8792
+ }
8793
+ }
8794
+ this.security = Object.freeze(security);
8702
8795
  this.serviceNames = new Set();
8703
8796
  this.wss = {};
8704
8797
  this.port = null;
@@ -8960,6 +9053,12 @@ class BohoAuth {
8960
9053
 
8961
9054
  async verify_auth_req(auth_req, peer) {
8962
9055
  try {
9056
+ if (!peer.checkAuthChallenge()) return;
9057
+ if (!(auth_req instanceof Uint8Array) || auth_req.length !== it.MetaSize.AUTH_REQ ||
9058
+ auth_req[0] !== it.BohoMsg.AUTH_REQ) {
9059
+ peer.securityMismatch('INVALID_AUTH_PACKET');
9060
+ return;
9061
+ }
8963
9062
  //1. unpack
8964
9063
  let infoPack = $$1.unpack(auth_req, it.Meta.AUTH_REQ);
8965
9064
  if (!infoPack) {
@@ -8976,7 +9075,7 @@ class BohoAuth {
8976
9075
 
8977
9076
  //2. get key of id from DB
8978
9077
  let authInfo = await this.keyProvider.getAuth(id);
8979
- if (peer._closing || peer.state === STATE.CLOSED) return;
9078
+ if (!peer.checkAuthChallenge()) return;
8980
9079
  if (!authInfo) {
8981
9080
  this.send_auth_fail(peer, 'NO ID:' + id);
8982
9081
  return
@@ -9006,6 +9105,10 @@ class BohoAuth {
9006
9105
  return
9007
9106
  }
9008
9107
 
9108
+ // A valid proof consumes this challenge even if account policy rejects it.
9109
+ // Reauthentication requires a new connection, never a replay-state reset.
9110
+ peer._challengeUsed = true;
9111
+
9009
9112
  //4. get info
9010
9113
 
9011
9114
  //5. check duplicate login.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iosignal",
3
- "version": "6.1.1",
3
+ "version": "7.0.0",
4
4
  "description": "iosignal",
5
5
  "type": "module",
6
6
  "exports": {