@mt-tl/server 0.1.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.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/auth/handshake.d.ts +35 -0
  4. package/dist/auth/handshake.d.ts.map +1 -0
  5. package/dist/auth/handshake.js +208 -0
  6. package/dist/auth/handshake.js.map +1 -0
  7. package/dist/auth/nonce-store.d.ts +22 -0
  8. package/dist/auth/nonce-store.d.ts.map +1 -0
  9. package/dist/auth/nonce-store.js +23 -0
  10. package/dist/auth/nonce-store.js.map +1 -0
  11. package/dist/bootstrap.d.ts +36 -0
  12. package/dist/bootstrap.d.ts.map +1 -0
  13. package/dist/bootstrap.js +82 -0
  14. package/dist/bootstrap.js.map +1 -0
  15. package/dist/config.d.ts +103 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +2 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/core/context.d.ts +57 -0
  20. package/dist/core/context.d.ts.map +1 -0
  21. package/dist/core/context.js +27 -0
  22. package/dist/core/context.js.map +1 -0
  23. package/dist/core/errors.d.ts +33 -0
  24. package/dist/core/errors.d.ts.map +1 -0
  25. package/dist/core/errors.js +47 -0
  26. package/dist/core/errors.js.map +1 -0
  27. package/dist/core/index.d.ts +5 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/index.js +5 -0
  30. package/dist/core/index.js.map +1 -0
  31. package/dist/core/rpc.d.ts +83 -0
  32. package/dist/core/rpc.d.ts.map +1 -0
  33. package/dist/core/rpc.js +102 -0
  34. package/dist/core/rpc.js.map +1 -0
  35. package/dist/core/updates.d.ts +56 -0
  36. package/dist/core/updates.d.ts.map +1 -0
  37. package/dist/core/updates.js +34 -0
  38. package/dist/core/updates.js.map +1 -0
  39. package/dist/create-server.d.ts +89 -0
  40. package/dist/create-server.d.ts.map +1 -0
  41. package/dist/create-server.js +109 -0
  42. package/dist/create-server.js.map +1 -0
  43. package/dist/crypto/aes-ige.d.ts +3 -0
  44. package/dist/crypto/aes-ige.d.ts.map +1 -0
  45. package/dist/crypto/aes-ige.js +55 -0
  46. package/dist/crypto/aes-ige.js.map +1 -0
  47. package/dist/crypto/dh.d.ts +21 -0
  48. package/dist/crypto/dh.d.ts.map +1 -0
  49. package/dist/crypto/dh.js +99 -0
  50. package/dist/crypto/dh.js.map +1 -0
  51. package/dist/crypto/hashes.d.ts +6 -0
  52. package/dist/crypto/hashes.d.ts.map +1 -0
  53. package/dist/crypto/hashes.js +14 -0
  54. package/dist/crypto/hashes.js.map +1 -0
  55. package/dist/crypto/msg-key.d.ts +15 -0
  56. package/dist/crypto/msg-key.d.ts.map +1 -0
  57. package/dist/crypto/msg-key.js +24 -0
  58. package/dist/crypto/msg-key.js.map +1 -0
  59. package/dist/crypto/rsa.d.ts +27 -0
  60. package/dist/crypto/rsa.d.ts.map +1 -0
  61. package/dist/crypto/rsa.js +50 -0
  62. package/dist/crypto/rsa.js.map +1 -0
  63. package/dist/dispatch/dispatcher.d.ts +72 -0
  64. package/dist/dispatch/dispatcher.d.ts.map +1 -0
  65. package/dist/dispatch/dispatcher.js +503 -0
  66. package/dist/dispatch/dispatcher.js.map +1 -0
  67. package/dist/dispatch/forwarders/in-process.d.ts +12 -0
  68. package/dist/dispatch/forwarders/in-process.d.ts.map +1 -0
  69. package/dist/dispatch/forwarders/in-process.js +15 -0
  70. package/dist/dispatch/forwarders/in-process.js.map +1 -0
  71. package/dist/dispatch/forwarders/print.d.ts +14 -0
  72. package/dist/dispatch/forwarders/print.d.ts.map +1 -0
  73. package/dist/dispatch/forwarders/print.js +23 -0
  74. package/dist/dispatch/forwarders/print.js.map +1 -0
  75. package/dist/dispatch/rpc-forwarder.d.ts +14 -0
  76. package/dist/dispatch/rpc-forwarder.d.ts.map +1 -0
  77. package/dist/dispatch/rpc-forwarder.js +2 -0
  78. package/dist/dispatch/rpc-forwarder.js.map +1 -0
  79. package/dist/dispatch/types.d.ts +32 -0
  80. package/dist/dispatch/types.d.ts.map +1 -0
  81. package/dist/dispatch/types.js +23 -0
  82. package/dist/dispatch/types.js.map +1 -0
  83. package/dist/gateway.d.ts +57 -0
  84. package/dist/gateway.d.ts.map +1 -0
  85. package/dist/gateway.js +150 -0
  86. package/dist/gateway.js.map +1 -0
  87. package/dist/index.d.ts +7 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +25 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/lib.d.ts +12 -0
  92. package/dist/lib.d.ts.map +1 -0
  93. package/dist/lib.js +13 -0
  94. package/dist/lib.js.map +1 -0
  95. package/dist/server/message-pipeline.d.ts +57 -0
  96. package/dist/server/message-pipeline.d.ts.map +1 -0
  97. package/dist/server/message-pipeline.js +199 -0
  98. package/dist/server/message-pipeline.js.map +1 -0
  99. package/dist/session/inbound-tracker.d.ts +118 -0
  100. package/dist/session/inbound-tracker.d.ts.map +1 -0
  101. package/dist/session/inbound-tracker.js +170 -0
  102. package/dist/session/inbound-tracker.js.map +1 -0
  103. package/dist/session/message-id.d.ts +19 -0
  104. package/dist/session/message-id.d.ts.map +1 -0
  105. package/dist/session/message-id.js +30 -0
  106. package/dist/session/message-id.js.map +1 -0
  107. package/dist/session/salts.d.ts +51 -0
  108. package/dist/session/salts.d.ts.map +1 -0
  109. package/dist/session/salts.js +132 -0
  110. package/dist/session/salts.js.map +1 -0
  111. package/dist/session/session-manager.d.ts +18 -0
  112. package/dist/session/session-manager.d.ts.map +1 -0
  113. package/dist/session/session-manager.js +43 -0
  114. package/dist/session/session-manager.js.map +1 -0
  115. package/dist/storage/index.d.ts +14 -0
  116. package/dist/storage/index.d.ts.map +1 -0
  117. package/dist/storage/index.js +16 -0
  118. package/dist/storage/index.js.map +1 -0
  119. package/dist/storage/memory.d.ts +3 -0
  120. package/dist/storage/memory.d.ts.map +1 -0
  121. package/dist/storage/memory.js +91 -0
  122. package/dist/storage/memory.js.map +1 -0
  123. package/dist/storage/mongo.d.ts +3 -0
  124. package/dist/storage/mongo.d.ts.map +1 -0
  125. package/dist/storage/mongo.js +175 -0
  126. package/dist/storage/mongo.js.map +1 -0
  127. package/dist/storage/types.d.ts +85 -0
  128. package/dist/storage/types.d.ts.map +1 -0
  129. package/dist/storage/types.js +3 -0
  130. package/dist/storage/types.js.map +1 -0
  131. package/dist/testkit.d.ts +11 -0
  132. package/dist/testkit.d.ts.map +1 -0
  133. package/dist/testkit.js +17 -0
  134. package/dist/testkit.js.map +1 -0
  135. package/dist/tl/codec.d.ts +37 -0
  136. package/dist/tl/codec.d.ts.map +1 -0
  137. package/dist/tl/codec.js +297 -0
  138. package/dist/tl/codec.js.map +1 -0
  139. package/dist/tl/layered-registry.d.ts +29 -0
  140. package/dist/tl/layered-registry.d.ts.map +1 -0
  141. package/dist/tl/layered-registry.js +118 -0
  142. package/dist/tl/layered-registry.js.map +1 -0
  143. package/dist/tl/protocol.d.ts +126 -0
  144. package/dist/tl/protocol.d.ts.map +1 -0
  145. package/dist/tl/protocol.js +22 -0
  146. package/dist/tl/protocol.js.map +1 -0
  147. package/dist/tl/reader.d.ts +30 -0
  148. package/dist/tl/reader.d.ts.map +1 -0
  149. package/dist/tl/reader.js +87 -0
  150. package/dist/tl/reader.js.map +1 -0
  151. package/dist/tl/registry.d.ts +39 -0
  152. package/dist/tl/registry.d.ts.map +1 -0
  153. package/dist/tl/registry.js +52 -0
  154. package/dist/tl/registry.js.map +1 -0
  155. package/dist/tl/writer.d.ts +24 -0
  156. package/dist/tl/writer.d.ts.map +1 -0
  157. package/dist/tl/writer.js +95 -0
  158. package/dist/tl/writer.js.map +1 -0
  159. package/dist/transport/connection-registry.d.ts +31 -0
  160. package/dist/transport/connection-registry.d.ts.map +1 -0
  161. package/dist/transport/connection-registry.js +84 -0
  162. package/dist/transport/connection-registry.js.map +1 -0
  163. package/dist/transport/connection.d.ts +62 -0
  164. package/dist/transport/connection.d.ts.map +1 -0
  165. package/dist/transport/connection.js +77 -0
  166. package/dist/transport/connection.js.map +1 -0
  167. package/dist/transport/framing.d.ts +39 -0
  168. package/dist/transport/framing.d.ts.map +1 -0
  169. package/dist/transport/framing.js +212 -0
  170. package/dist/transport/framing.js.map +1 -0
  171. package/dist/transport/proxy-protocol.d.ts +23 -0
  172. package/dist/transport/proxy-protocol.d.ts.map +1 -0
  173. package/dist/transport/proxy-protocol.js +108 -0
  174. package/dist/transport/proxy-protocol.js.map +1 -0
  175. package/dist/transport/server-common.d.ts +27 -0
  176. package/dist/transport/server-common.d.ts.map +1 -0
  177. package/dist/transport/server-common.js +27 -0
  178. package/dist/transport/server-common.js.map +1 -0
  179. package/dist/transport/tcp-server.d.ts +26 -0
  180. package/dist/transport/tcp-server.d.ts.map +1 -0
  181. package/dist/transport/tcp-server.js +91 -0
  182. package/dist/transport/tcp-server.js.map +1 -0
  183. package/dist/transport/ws-server.d.ts +19 -0
  184. package/dist/transport/ws-server.d.ts.map +1 -0
  185. package/dist/transport/ws-server.js +78 -0
  186. package/dist/transport/ws-server.js.map +1 -0
  187. package/dist/update-publisher.d.ts +34 -0
  188. package/dist/update-publisher.d.ts.map +1 -0
  189. package/dist/update-publisher.js +29 -0
  190. package/dist/update-publisher.js.map +1 -0
  191. package/dist/updates/mongo-update-log.d.ts +13 -0
  192. package/dist/updates/mongo-update-log.d.ts.map +1 -0
  193. package/dist/updates/mongo-update-log.js +39 -0
  194. package/dist/updates/mongo-update-log.js.map +1 -0
  195. package/dist/updates/presence-binder.d.ts +29 -0
  196. package/dist/updates/presence-binder.d.ts.map +1 -0
  197. package/dist/updates/presence-binder.js +36 -0
  198. package/dist/updates/presence-binder.js.map +1 -0
  199. package/dist/updates/presence.d.ts +31 -0
  200. package/dist/updates/presence.d.ts.map +1 -0
  201. package/dist/updates/presence.js +44 -0
  202. package/dist/updates/presence.js.map +1 -0
  203. package/dist/updates/push.d.ts +25 -0
  204. package/dist/updates/push.d.ts.map +1 -0
  205. package/dist/updates/push.js +72 -0
  206. package/dist/updates/push.js.map +1 -0
  207. package/dist/updates/redis-bus.d.ts +45 -0
  208. package/dist/updates/redis-bus.d.ts.map +1 -0
  209. package/dist/updates/redis-bus.js +59 -0
  210. package/dist/updates/redis-bus.js.map +1 -0
  211. package/dist/updates/redis-presence.d.ts +43 -0
  212. package/dist/updates/redis-presence.d.ts.map +1 -0
  213. package/dist/updates/redis-presence.js +65 -0
  214. package/dist/updates/redis-presence.js.map +1 -0
  215. package/dist/updates/render.d.ts +16 -0
  216. package/dist/updates/render.d.ts.map +1 -0
  217. package/dist/updates/render.js +46 -0
  218. package/dist/updates/render.js.map +1 -0
  219. package/dist/updates/router.d.ts +27 -0
  220. package/dist/updates/router.d.ts.map +1 -0
  221. package/dist/updates/router.js +36 -0
  222. package/dist/updates/router.js.map +1 -0
  223. package/dist/updates/types.d.ts +23 -0
  224. package/dist/updates/types.d.ts.map +1 -0
  225. package/dist/updates/types.js +2 -0
  226. package/dist/updates/types.js.map +1 -0
  227. package/dist/updates/update-bus.d.ts +29 -0
  228. package/dist/updates/update-bus.d.ts.map +1 -0
  229. package/dist/updates/update-bus.js +24 -0
  230. package/dist/updates/update-bus.js.map +1 -0
  231. package/dist/util/bytes.d.ts +12 -0
  232. package/dist/util/bytes.d.ts.map +1 -0
  233. package/dist/util/bytes.js +46 -0
  234. package/dist/util/bytes.js.map +1 -0
  235. package/package.json +84 -0
  236. package/src/auth/handshake.ts +262 -0
  237. package/src/auth/nonce-store.ts +39 -0
  238. package/src/bootstrap.ts +114 -0
  239. package/src/config.ts +103 -0
  240. package/src/core/context.ts +94 -0
  241. package/src/core/errors.ts +52 -0
  242. package/src/core/index.ts +4 -0
  243. package/src/core/rpc.ts +165 -0
  244. package/src/core/updates.ts +69 -0
  245. package/src/create-server.ts +181 -0
  246. package/src/crypto/aes-ige.ts +57 -0
  247. package/src/crypto/dh.ts +101 -0
  248. package/src/crypto/hashes.ts +17 -0
  249. package/src/crypto/msg-key.ts +29 -0
  250. package/src/crypto/rsa.ts +70 -0
  251. package/src/dispatch/dispatcher.ts +586 -0
  252. package/src/dispatch/forwarders/in-process.ts +14 -0
  253. package/src/dispatch/forwarders/print.ts +22 -0
  254. package/src/dispatch/rpc-forwarder.ts +15 -0
  255. package/src/dispatch/types.ts +60 -0
  256. package/src/gateway.ts +214 -0
  257. package/src/index.ts +53 -0
  258. package/src/lib.ts +24 -0
  259. package/src/server/message-pipeline.ts +256 -0
  260. package/src/session/inbound-tracker.ts +221 -0
  261. package/src/session/message-id.ts +43 -0
  262. package/src/session/salts.ts +162 -0
  263. package/src/session/session-manager.ts +66 -0
  264. package/src/storage/index.ts +26 -0
  265. package/src/storage/memory.ts +101 -0
  266. package/src/storage/mongo.ts +215 -0
  267. package/src/storage/types.ts +92 -0
  268. package/src/testkit.ts +19 -0
  269. package/src/tl/codec.ts +292 -0
  270. package/src/tl/layered-registry.ts +132 -0
  271. package/src/tl/protocol.ts +146 -0
  272. package/src/tl/reader.ts +99 -0
  273. package/src/tl/registry.ts +78 -0
  274. package/src/tl/writer.ts +104 -0
  275. package/src/transport/connection-registry.ts +91 -0
  276. package/src/transport/connection.ts +113 -0
  277. package/src/transport/framing.ts +223 -0
  278. package/src/transport/proxy-protocol.ts +109 -0
  279. package/src/transport/server-common.ts +49 -0
  280. package/src/transport/tcp-server.ts +102 -0
  281. package/src/transport/ws-server.ts +94 -0
  282. package/src/update-publisher.ts +47 -0
  283. package/src/updates/mongo-update-log.ts +49 -0
  284. package/src/updates/presence-binder.ts +51 -0
  285. package/src/updates/presence.ts +61 -0
  286. package/src/updates/push.ts +90 -0
  287. package/src/updates/redis-bus.ts +86 -0
  288. package/src/updates/redis-presence.ts +87 -0
  289. package/src/updates/render.ts +53 -0
  290. package/src/updates/router.ts +52 -0
  291. package/src/updates/types.ts +24 -0
  292. package/src/updates/update-bus.ts +49 -0
  293. package/src/util/bytes.ts +49 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Per-connection inbound-message guard and state tracker.
3
+ *
4
+ * Implements the client→server half of the MTProto message-id / sequence-number
5
+ * rules (https://core.telegram.org/mtproto/description) and backs the
6
+ * `msgs_state_req` / `msgs_state_info` service messages
7
+ * (https://core.telegram.org/mtproto/service_messages_about_messages).
8
+ *
9
+ * A connection carries a single session's message stream, so one tracker per
10
+ * connection is sufficient. The tracker is purely in-memory: replay protection
11
+ * is per-connection-process, while the time-window check (codes 16/17) bounds
12
+ * cross-process replay since stale msg_ids are rejected everywhere.
13
+ *
14
+ * Sequence-number parity (34/35) and ordering (32) are enforced when `checkSeqNo`
15
+ * / `checkOrder` are set (gated by the `disableSeqNoCheck` config). Code 33 (seqno
16
+ * too high) is unreachable under serial in-order processing; code 64 (invalid
17
+ * container) is raised by the dispatcher. The outer envelope and each
18
+ * container-inner message both run through {@link InboundTracker.accept}. See
19
+ * docs/internals/protocol-compliance.md.
20
+ */
21
+
22
+ /** `bad_msg_notification` error codes. 16–35 come from {@link InboundTracker.accept};
23
+ * 64 (invalid container) is raised by the dispatcher. */
24
+ export type BadMsgCode = 16 | 17 | 18 | 19 | 20 | 32 | 33 | 34 | 35 | 64
25
+
26
+ /** The cached answer (`rpc_result`) to a request, for `msg_detailed_info`. */
27
+ export interface CachedAnswer {
28
+ answerMsgId: bigint
29
+ bytes: number
30
+ }
31
+
32
+ export type AcceptResult =
33
+ | { ok: true }
34
+ /** Reject and reply `bad_msg_notification` with this code. */
35
+ | { ok: false; code: BadMsgCode }
36
+ /** Duplicate of an already-answered request — reply `msg_detailed_info`. */
37
+ | { ok: false; detailed: CachedAnswer }
38
+ /** Benign duplicate with no cached answer — drop silently, send no reply. */
39
+ | { ok: false; drop: true }
40
+
41
+ /** Classification of an inbound message, derived from its constructor id. */
42
+ export interface AcceptOptions {
43
+ /** The payload is a `msg_container` (a duplicate of one is a protocol error → 19). */
44
+ isContainer?: boolean
45
+ /** The message requires acknowledgment (RPC queries); odd seqno expected. */
46
+ contentRelated?: boolean
47
+ /** Enforce seqno parity (codes 34/35). */
48
+ checkSeqNo?: boolean
49
+ /** Enforce content-seqno ordering (code 32). Only the top-level stream — inner
50
+ * container messages are skipped, since a resend container carries old seqnos. */
51
+ checkOrder?: boolean
52
+ }
53
+
54
+ interface InboundMsg {
55
+ seqNo: number
56
+ /** Odd seqno ⇒ content-related (an RPC query), even ⇒ pure service message. */
57
+ contentRelated: boolean
58
+ /** The reply we generated, once sent — lets a later duplicate get `msg_detailed_info`. */
59
+ answer?: CachedAnswer
60
+ }
61
+
62
+ // Constructor ids of client→server messages that do NOT require acknowledgment
63
+ // (carry an even seqno). Everything else is content-related (odd seqno).
64
+ const ID_MSG_CONTAINER = 0x73f1f8dc
65
+ const NON_CONTENT_IDS = new Set<number>([
66
+ ID_MSG_CONTAINER, // msg_container
67
+ 0x62d6b459, // msgs_ack
68
+ 0x7abe77ec, // ping
69
+ 0xf3427b8c, // ping_delay_disconnect
70
+ 0x9299359f, // http_wait
71
+ 0x8cc0d131, // msgs_all_info
72
+ ])
73
+
74
+ /** Classify a raw payload by its leading constructor id (used to drive `accept`). */
75
+ export function messageClass(payload: Buffer): { isContainer: boolean; contentRelated: boolean } {
76
+ if (payload.length < 4) return { isContainer: false, contentRelated: true }
77
+ const id = payload.readUInt32LE(0)
78
+ return { isContainer: id === ID_MSG_CONTAINER, contentRelated: !NON_CONTENT_IDS.has(id) }
79
+ }
80
+
81
+ export interface InboundTrackerOptions {
82
+ /** Clock in milliseconds (default `Date.now`); injectable for tests. */
83
+ nowMs?: () => number
84
+ /** Reject msg_ids whose timestamp is more than this far in the future. */
85
+ futureToleranceSec?: number
86
+ /** Reject msg_ids whose timestamp is more than this far in the past. */
87
+ pastToleranceSec?: number
88
+ /** Size of the recent-msg_id window kept for dedup/state (FIFO eviction). */
89
+ maxTracked?: number
90
+ }
91
+
92
+ // Spec tolerances: a client msg_id encodes unix time in its high 32 bits and must
93
+ // be divisible by 4. Telegram ignores ids more than 30s ahead or 300s behind.
94
+ const FUTURE_TOLERANCE_SEC = 30
95
+ const PAST_TOLERANCE_SEC = 300
96
+ const MAX_TRACKED = 1024
97
+
98
+ export class InboundTracker {
99
+ private readonly received = new Map<bigint, InboundMsg>()
100
+ /** Insertion order, for FIFO eviction once the window is full. */
101
+ private readonly order: bigint[] = []
102
+ /** Highest msg_id evicted from the window — anything ≤ this is "too old to verify". */
103
+ private evictedHigh = 0n
104
+ /** Highest msg_id ever accepted (distinguishes "in range" from "too high"). */
105
+ private maxReceived = 0n
106
+ /** Highest odd seqno of a content-related message seen (for ordering code 32). */
107
+ private lastContentSeqNo = -1
108
+
109
+ private readonly now: () => number
110
+ private readonly futureTolerance: number
111
+ private readonly pastTolerance: number
112
+ private readonly maxTracked: number
113
+
114
+ constructor(opts: InboundTrackerOptions = {}) {
115
+ this.now = opts.nowMs ?? (() => Date.now())
116
+ this.futureTolerance = opts.futureToleranceSec ?? FUTURE_TOLERANCE_SEC
117
+ this.pastTolerance = opts.pastToleranceSec ?? PAST_TOLERANCE_SEC
118
+ this.maxTracked = Math.max(1, opts.maxTracked ?? MAX_TRACKED)
119
+ }
120
+
121
+ /**
122
+ * Validate and record an inbound message. Returns the `bad_msg_notification`
123
+ * error code if the message must be rejected, `{ drop: true }` for a benign
124
+ * duplicate to ignore silently, or `{ ok: true }` if it should be processed. A
125
+ * rejected message is NOT recorded, so the client may correct and resend it
126
+ * (e.g. after a `bad_server_salt`, which re-uses the same msg_id).
127
+ */
128
+ accept(msgId: bigint, seqNo: number, opts: AcceptOptions = {}): AcceptResult {
129
+ const { isContainer = false, contentRelated = true, checkSeqNo = false, checkOrder = false } = opts
130
+
131
+ // 18: the two low bits of a client msg_id must be 0 (divisible by 4).
132
+ if ((msgId & 3n) !== 0n) return { ok: false, code: 18 }
133
+
134
+ const nowSec = Math.floor(this.now() / 1000)
135
+ const msgSec = Number(msgId >> 32n)
136
+ // 16 / 17: client clock skew beyond tolerance.
137
+ if (msgSec < nowSec - this.pastTolerance) return { ok: false, code: 16 }
138
+ if (msgSec > nowSec + this.futureTolerance) return { ok: false, code: 17 }
139
+
140
+ const dup = this.received.get(msgId)
141
+ if (dup) {
142
+ // A duplicate container msg_id is a protocol error (19). For a duplicate
143
+ // regular message: reply `msg_detailed_info` if its answer is still cached,
144
+ // else drop silently — re-processing is unsafe and a bad_msg reply would
145
+ // wrongly make the client resync its clock/salt.
146
+ if (isContainer) return { ok: false, code: 19 }
147
+ return dup.answer ? { ok: false, detailed: dup.answer } : { ok: false, drop: true }
148
+ }
149
+ // 20: older than anything we still remember — can't verify it's not a replay.
150
+ if (msgId <= this.evictedHigh) return { ok: false, code: 20 }
151
+
152
+ if (checkSeqNo) {
153
+ const odd = seqNo % 2 === 1
154
+ // 34/35: content-related messages carry an odd seqno, pure service ones even.
155
+ if (contentRelated && !odd) return { ok: false, code: 35 }
156
+ if (!contentRelated && odd) return { ok: false, code: 34 }
157
+ }
158
+ if (checkOrder && contentRelated) {
159
+ // 32: a content-related seqno must exceed every earlier one (they arrive in
160
+ // msg_id order). Code 33 (too high) can't occur under serial processing.
161
+ if (seqNo <= this.lastContentSeqNo) return { ok: false, code: 32 }
162
+ this.lastContentSeqNo = seqNo
163
+ }
164
+
165
+ this.note(msgId, seqNo)
166
+ return { ok: true }
167
+ }
168
+
169
+ /**
170
+ * Record the reply (`rpc_result`) generated for a request, so a later duplicate
171
+ * of that request can be answered with `msg_detailed_info` instead of dropped.
172
+ * No-op if the request id is no longer tracked.
173
+ */
174
+ recordAnswer(reqMsgId: bigint, answerMsgId: bigint, bytes: number): void {
175
+ const e = this.received.get(reqMsgId)
176
+ if (e) e.answer = { answerMsgId, bytes }
177
+ }
178
+
179
+ /**
180
+ * Record a received msg_id without validation. Used for messages observed
181
+ * inside a container (their ids are not individually validated), so that
182
+ * `msgs_state_req` can still report them as received.
183
+ */
184
+ note(msgId: bigint, seqNo: number): void {
185
+ if (this.received.has(msgId)) return
186
+ this.received.set(msgId, { seqNo, contentRelated: seqNo % 2 === 1 })
187
+ this.order.push(msgId)
188
+ if (msgId > this.maxReceived) this.maxReceived = msgId
189
+ while (this.order.length > this.maxTracked) {
190
+ const evicted = this.order.shift()!
191
+ this.received.delete(evicted)
192
+ if (evicted > this.evictedHigh) this.evictedHigh = evicted
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Per-id status bytes for `msgs_state_info.info` — one byte per requested id
198
+ * (see the spec's status-byte table). Reports received messages as state 4
199
+ * with the appropriate high bits, and unseen ids as 1/2/3 by position relative
200
+ * to the tracking window.
201
+ */
202
+ stateOf(ids: bigint[]): Buffer {
203
+ const nowSec = Math.floor(this.now() / 1000)
204
+ return Buffer.from(ids.map(id => this.stateByte(id, nowSec)))
205
+ }
206
+
207
+ private stateByte(id: bigint, nowSec: number): number {
208
+ const e = this.received.get(id)
209
+ if (e) {
210
+ // 4 = received. Pure service messages don't require an ack (+16);
211
+ // content-related queries are processed and answered (+32 +64).
212
+ return e.contentRelated ? 4 + 32 + 64 : 4 + 16
213
+ }
214
+ const msgSec = Number(id >> 32n)
215
+ // 1 = nothing known (too old / already forgotten); 3 = too high (not yet
216
+ // received); 2 = within the known range but not received.
217
+ if (msgSec < nowSec - this.pastTolerance || id <= this.evictedHigh) return 1
218
+ if (id > this.maxReceived) return 3
219
+ return 2
220
+ }
221
+ }
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * MTProto message-id and sequence-number generation, ported from the existing
5
+ * server (`mtproto-tools.generateId` + `generateMessageId`/`generateMessageSeqNo`).
6
+ *
7
+ * A message id encodes a unix timestamp in its high bits; the low 2 bits encode
8
+ * direction/intent: server responses end in 1, notifications in 3. Ids are kept
9
+ * strictly increasing per connection.
10
+ */
11
+ export interface MsgIdState {
12
+ lastMessageId: bigint | null
13
+ messageSeqNo: number
14
+ }
15
+
16
+ function generateId(): bigint {
17
+ const ticks = Date.now()
18
+ const timeSec = Math.floor(ticks / 1000)
19
+ const timeMSec = ticks % 1000
20
+ const random = randomBytes(2).readUInt16LE(0)
21
+ return (BigInt(timeSec) << 32n) | BigInt((timeMSec << 21) | (random << 3) | 4)
22
+ }
23
+
24
+ export function nextMessageId(state: MsgIdState, isNotification = false): bigint {
25
+ let id = generateId()
26
+ if (state.lastMessageId !== null && id <= state.lastMessageId) {
27
+ id = state.lastMessageId + 1n
28
+ }
29
+ const target = isNotification ? 3n : 1n
30
+ while (id % 4n !== target) id++
31
+ state.lastMessageId = id
32
+ return id
33
+ }
34
+
35
+ /**
36
+ * Sequence number for an outgoing message. Content-related messages (rpc_result,
37
+ * updates) consume a slot and get an odd seqno; pure service messages do not.
38
+ */
39
+ export function nextSeqNo(state: MsgIdState, contentRelated = true): number {
40
+ const seq = state.messageSeqNo
41
+ if (contentRelated) state.messageSeqNo++
42
+ return seq * 2 + (contentRelated ? 1 : 0)
43
+ }
@@ -0,0 +1,162 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { toBigIntLE } from '../util/bytes.js'
3
+ import type { SaltRepo, SaltScheduleEntry } from '../storage/types.js'
4
+
5
+ /**
6
+ * Server-salt scheduler — the spec-faithful core of the salt subsystem
7
+ * (https://core.telegram.org/mtproto/service_messages).
8
+ *
9
+ * Each auth key gets a rolling schedule of 64-bit salts, each valid for a bounded
10
+ * window (~30 min) with overlap: a new salt is minted before the previous expires,
11
+ * so there is always a current salt and a ready successor.
12
+ *
13
+ * Windows lie on a deterministic grid anchored at the first (handshake-derived)
14
+ * salt's `validSince`: window `k` is `[t0 + k·step, t0 + k·step + window)`. Because
15
+ * the anchor is persisted and `step`/`window` are constants, every gateway node
16
+ * derives the same window boundaries and (via the repo's insert-if-absent
17
+ * semantics) converges on one salt per window — so any node validates any salt.
18
+ */
19
+
20
+ const DEFAULT_WINDOW_SEC = 30 * 60
21
+ const DEFAULT_STEP_SEC = 15 * 60
22
+
23
+ export interface SaltServiceOptions {
24
+ /** Validity length of each salt, seconds (default 1800 = 30 min). */
25
+ windowSec?: number
26
+ /** Spacing between consecutive window starts, seconds. Must be `<= windowSec`
27
+ * for overlap (default 900 = 15 min, giving two concurrently-valid salts). */
28
+ stepSec?: number
29
+ /** Windows to keep minted ahead of the current one (default 1). */
30
+ prefetch?: number
31
+ /** Injectable clock returning unix seconds (default `Date.now()/1000`). */
32
+ nowSec?: () => number
33
+ }
34
+
35
+ /** Result of {@link SaltService.resolve}: the salt to advertise + whether the
36
+ * salt the client used is currently valid. */
37
+ export interface SaltCheck {
38
+ current: bigint
39
+ valid: boolean
40
+ }
41
+
42
+ export class SaltService {
43
+ private readonly window: number
44
+ private readonly step: number
45
+ private readonly prefetch: number
46
+ private readonly now: () => number
47
+
48
+ constructor(
49
+ private readonly repo: SaltRepo,
50
+ opts: SaltServiceOptions = {},
51
+ ) {
52
+ this.window = opts.windowSec ?? DEFAULT_WINDOW_SEC
53
+ this.step = opts.stepSec ?? DEFAULT_STEP_SEC
54
+ this.prefetch = Math.max(0, opts.prefetch ?? 1)
55
+ this.now = opts.nowSec ?? (() => Math.floor(Date.now() / 1000))
56
+ if (this.step <= 0 || this.step > this.window) {
57
+ throw new Error('SaltService: require 0 < stepSec <= windowSec for overlapping windows')
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Seed the schedule's first window from the handshake-derived salt. Idempotent
63
+ * (a no-op if a schedule already exists), and wire-compatible: the first salt
64
+ * keeps its `xor(newNonce, serverNonce)` value.
65
+ */
66
+ async seed(authKeyId: bigint, firstSalt: bigint): Promise<void> {
67
+ if ((await this.repo.list(authKeyId)).length) return
68
+ const since = this.now()
69
+ await this.repo.append(authKeyId, [
70
+ { salt: firstSalt, validSince: since, validUntil: since + this.window },
71
+ ])
72
+ }
73
+
74
+ /**
75
+ * Advertise the current salt and report whether `clientSalt` is valid right
76
+ * now. Mints the current window (and `prefetch` successors) on demand. The
77
+ * decrypt path uses this to drive `bad_server_salt`.
78
+ */
79
+ async resolve(authKeyId: bigint, clientSalt: bigint): Promise<SaltCheck> {
80
+ const now = this.now()
81
+ const list = await this.ensure(authKeyId, now, this.prefetch)
82
+ return {
83
+ current: pickCurrent(list, now).salt,
84
+ valid: list.some(e => covers(e, now) && e.salt === clientSalt),
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The next `num` scheduled salts starting from the current window, minting
90
+ * more if the schedule is short. Backs `get_future_salts(num)`.
91
+ */
92
+ async future(authKeyId: bigint, num: number): Promise<SaltScheduleEntry[]> {
93
+ const n = Math.max(1, num)
94
+ const now = this.now()
95
+ const list = await this.ensure(authKeyId, now, n - 1)
96
+ const { t0, kNow } = grid(list, now, this.step)
97
+ const out: SaltScheduleEntry[] = []
98
+ for (let k = kNow; k < kNow + n; k++) {
99
+ const since = t0 + k * this.step
100
+ const e = list.find(x => x.validSince === since)
101
+ if (e) out.push(e)
102
+ }
103
+ return out
104
+ }
105
+
106
+ /**
107
+ * Ensure the schedule contains the window covering `now` plus `ahead` further
108
+ * grid windows; return the refreshed, ascending schedule. Opportunistically
109
+ * prunes windows that expired more than one window ago.
110
+ */
111
+ private async ensure(authKeyId: bigint, now: number, ahead: number): Promise<SaltScheduleEntry[]> {
112
+ let list = await this.repo.list(authKeyId)
113
+ if (!list.length) {
114
+ // Defensive: no handshake seed (e.g. get_future_salts on a key with no
115
+ // persisted schedule). Anchor a fresh grid at now.
116
+ await this.repo.append(authKeyId, [this.windowAt(now, randomSalt())])
117
+ list = await this.repo.list(authKeyId)
118
+ }
119
+
120
+ const { t0, kNow } = grid(list, now, this.step)
121
+ const missing: SaltScheduleEntry[] = []
122
+ for (let k = kNow; k <= kNow + ahead; k++) {
123
+ const since = t0 + k * this.step
124
+ if (!list.some(e => e.validSince === since)) missing.push(this.windowAt(since, randomSalt()))
125
+ }
126
+ if (missing.length) {
127
+ await this.repo.append(authKeyId, missing)
128
+ list = await this.repo.list(authKeyId)
129
+ }
130
+
131
+ // Keep one window of expired history (covers messages still in flight).
132
+ await this.repo.prune(authKeyId, now - this.window).catch(() => {})
133
+ return list
134
+ }
135
+
136
+ private windowAt(since: number, salt: bigint): SaltScheduleEntry {
137
+ return { salt, validSince: since, validUntil: since + this.window }
138
+ }
139
+ }
140
+
141
+ function covers(e: SaltScheduleEntry, now: number): boolean {
142
+ return e.validSince <= now && now < e.validUntil
143
+ }
144
+
145
+ /** The grid anchor `t0` (first window start) and the index `kNow` of the window
146
+ * covering `now`. All windows sit on `t0 + k·step`, so any surviving entry is a
147
+ * valid anchor — pruning the first one keeps the grid aligned. */
148
+ function grid(list: SaltScheduleEntry[], now: number, step: number): { t0: number; kNow: number } {
149
+ const t0 = list[0]!.validSince
150
+ return { t0, kNow: Math.max(0, Math.floor((now - t0) / step)) }
151
+ }
152
+
153
+ /** Newest window covering `now`; falls back to the latest entry if a gap. */
154
+ function pickCurrent(list: SaltScheduleEntry[], now: number): SaltScheduleEntry {
155
+ let best: SaltScheduleEntry | undefined
156
+ for (const e of list) if (covers(e, now) && (!best || e.validSince > best.validSince)) best = e
157
+ return best ?? list[list.length - 1]!
158
+ }
159
+
160
+ function randomSalt(): bigint {
161
+ return toBigIntLE(randomBytes(8))
162
+ }
@@ -0,0 +1,66 @@
1
+ import { noopLogger, type Logger } from '@mt-tl/tl'
2
+ import type { Connection } from '../transport/connection.js'
3
+ import type { Storage } from '../storage/index.js'
4
+ import type { Responder } from '../dispatch/types.js'
5
+ import { randomBigInt } from '../crypto/hashes.js'
6
+
7
+ export interface SessionInfo {
8
+ sessionId: bigint
9
+ authKeyId: bigint
10
+ firstMsgId: bigint
11
+ subject?: string
12
+ }
13
+
14
+ /**
15
+ * Ensures a persisted session exists for the connection. On the first message
16
+ * of a new session, persists it and emits `new_session_created`. Ported from
17
+ * the existing `handleSessionMessage`, but the session is durable (storage),
18
+ * not an in-memory-only Map.
19
+ */
20
+ export async function ensureSession(
21
+ storage: Storage,
22
+ responder: Responder,
23
+ conn: Connection,
24
+ info: SessionInfo,
25
+ log: Logger = noopLogger,
26
+ ): Promise<void> {
27
+ const existing = await storage.sessions.get(info.sessionId)
28
+
29
+ if (existing && existing.authKeyId === info.authKeyId) {
30
+ await storage.sessions.touch(info.sessionId)
31
+ conn.ctx.uniqueId = existing.uniqueId
32
+ conn.ctx.apiLayer = existing.apiLayer
33
+ conn.ctx.subject = existing.subject
34
+ return
35
+ }
36
+ if (existing) await storage.sessions.delete(info.sessionId)
37
+
38
+ const uniqueId = randomBigInt(64)
39
+ conn.ctx.uniqueId = uniqueId
40
+ conn.ctx.subject = info.subject
41
+
42
+ await storage.sessions.save({
43
+ sessionId: info.sessionId,
44
+ authKeyId: info.authKeyId,
45
+ uniqueId,
46
+ apiLayer: conn.ctx.apiLayer,
47
+ subject: info.subject,
48
+ lastActivity: Date.now(),
49
+ })
50
+ log.info('session.new', {
51
+ sessionId: info.sessionId,
52
+ authKeyId: info.authKeyId,
53
+ subject: info.subject,
54
+ })
55
+
56
+ responder.sendEncrypted(
57
+ conn,
58
+ {
59
+ _: 'new_session_created',
60
+ first_msg_id: info.firstMsgId,
61
+ unique_id: uniqueId,
62
+ server_salt: conn.ctx.serverSalt ?? 0n,
63
+ },
64
+ { isNotification: true, contentRelated: false },
65
+ )
66
+ }
@@ -0,0 +1,26 @@
1
+ import type { Storage } from './types.js'
2
+ import { createMemoryStorage } from './memory.js'
3
+
4
+ export type { Storage } from './types.js'
5
+ export type StorageBackend = 'memory' | 'mongo'
6
+
7
+ export interface StorageConfig {
8
+ backend: StorageBackend
9
+ mongoUrl?: string
10
+ mongoDb?: string
11
+ }
12
+
13
+ /**
14
+ * Builds the configured storage backend. `memory` (default) needs no external
15
+ * services; `mongo` lazily imports the driver so memory mode stays dependency-free.
16
+ */
17
+ export async function createStorage(config: StorageConfig): Promise<Storage> {
18
+ if (config.backend === 'mongo') {
19
+ if (!config.mongoUrl || !config.mongoDb) {
20
+ throw new Error('STORAGE_BACKEND=mongo requires MONGO_URL and MONGO_DB')
21
+ }
22
+ const { createMongoStorage } = await import('./mongo.js')
23
+ return createMongoStorage(config.mongoUrl, config.mongoDb)
24
+ }
25
+ return createMemoryStorage()
26
+ }
@@ -0,0 +1,101 @@
1
+ import type {
2
+ AuthKeyMeta,
3
+ AuthKeyRecord,
4
+ AuthKeyRepo,
5
+ SaltRepo,
6
+ SaltScheduleEntry,
7
+ SessionRecord,
8
+ SessionRepo,
9
+ Storage,
10
+ } from './types.js'
11
+
12
+ /**
13
+ * In-memory storage. The default backend so the gateway runs with no external
14
+ * services (dev, tests). Auth keys/sessions do not survive a restart — use the
15
+ * Mongo backend for that.
16
+ */
17
+ class MemoryAuthKeyRepo implements AuthKeyRepo {
18
+ private map = new Map<string, AuthKeyRecord>()
19
+
20
+ async create(rec: AuthKeyRecord): Promise<void> {
21
+ this.map.set(rec.id.toString(), { ...rec })
22
+ }
23
+ async getById(id: bigint): Promise<AuthKeyRecord | null> {
24
+ return this.map.get(id.toString()) ?? null
25
+ }
26
+ async setBlocked(id: bigint, blocked: boolean): Promise<void> {
27
+ const rec = this.map.get(id.toString())
28
+ if (rec) rec.isBlocked = blocked
29
+ }
30
+ async bindUser(id: bigint, subject: string | null): Promise<void> {
31
+ const rec = this.map.get(id.toString())
32
+ if (rec) rec.subject = subject
33
+ }
34
+ async updateMeta(id: bigint, patch: AuthKeyMeta): Promise<void> {
35
+ const rec = this.map.get(id.toString())
36
+ if (!rec) return
37
+ const meta = (rec.meta ??= {})
38
+ for (const [k, v] of Object.entries(patch)) {
39
+ if (v !== undefined) (meta as Record<string, unknown>)[k] = v
40
+ }
41
+ }
42
+ }
43
+
44
+ class MemorySaltRepo implements SaltRepo {
45
+ private map = new Map<string, SaltScheduleEntry[]>()
46
+ async append(authKeyId: bigint, entries: SaltScheduleEntry[]): Promise<void> {
47
+ const k = authKeyId.toString()
48
+ const list = this.map.get(k) ?? []
49
+ for (const e of entries) {
50
+ // Insert-if-absent by window start; never overwrite an existing salt.
51
+ if (!list.some(x => x.validSince === e.validSince)) list.push({ ...e })
52
+ }
53
+ list.sort((a, b) => a.validSince - b.validSince)
54
+ this.map.set(k, list)
55
+ }
56
+ async list(authKeyId: bigint): Promise<SaltScheduleEntry[]> {
57
+ return (this.map.get(authKeyId.toString()) ?? []).map(e => ({ ...e }))
58
+ }
59
+ async prune(authKeyId: bigint, before: number): Promise<void> {
60
+ const k = authKeyId.toString()
61
+ const list = this.map.get(k)
62
+ if (list)
63
+ this.map.set(
64
+ k,
65
+ list.filter(e => e.validUntil > before),
66
+ )
67
+ }
68
+ }
69
+
70
+ class MemorySessionRepo implements SessionRepo {
71
+ private map = new Map<string, SessionRecord>()
72
+ async get(sessionId: bigint): Promise<SessionRecord | null> {
73
+ const r = this.map.get(sessionId.toString())
74
+ return r ? { ...r } : null
75
+ }
76
+ async save(rec: SessionRecord): Promise<void> {
77
+ this.map.set(rec.sessionId.toString(), { ...rec })
78
+ }
79
+ async update(sessionId: bigint, patch: Partial<SessionRecord>): Promise<void> {
80
+ const r = this.map.get(sessionId.toString())
81
+ if (r) this.map.set(sessionId.toString(), { ...r, ...patch })
82
+ }
83
+ async delete(sessionId: bigint): Promise<void> {
84
+ this.map.delete(sessionId.toString())
85
+ }
86
+ async touch(sessionId: bigint): Promise<boolean> {
87
+ const r = this.map.get(sessionId.toString())
88
+ if (!r) return false
89
+ r.lastActivity = Date.now()
90
+ return true
91
+ }
92
+ }
93
+
94
+ export function createMemoryStorage(): Storage {
95
+ return {
96
+ authKeys: new MemoryAuthKeyRepo(),
97
+ salts: new MemorySaltRepo(),
98
+ sessions: new MemorySessionRepo(),
99
+ async close() {},
100
+ }
101
+ }