fb-messenger-e2ee 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 (106) hide show
  1. package/DOCS.md +310 -0
  2. package/LICENSE +660 -0
  3. package/README.md +153 -0
  4. package/bun.lock +1014 -0
  5. package/examples/basic-usage.ts +52 -0
  6. package/jest.config.ts +18 -0
  7. package/package.json +56 -0
  8. package/src/config/env.ts +15 -0
  9. package/src/controllers/client.controller.ts +805 -0
  10. package/src/controllers/dgw-handler.ts +171 -0
  11. package/src/controllers/e2ee-handler.ts +832 -0
  12. package/src/controllers/event-mapper.ts +327 -0
  13. package/src/core/client.ts +151 -0
  14. package/src/e2ee/application/e2ee-client.ts +376 -0
  15. package/src/e2ee/application/fanout-planner.ts +79 -0
  16. package/src/e2ee/application/outbound-message-cache.ts +58 -0
  17. package/src/e2ee/application/prekey-maintenance.ts +55 -0
  18. package/src/e2ee/application/retry-manager.ts +156 -0
  19. package/src/e2ee/facebook/facebook-protocol-utils.ts +230 -0
  20. package/src/e2ee/facebook/icdc-payload.ts +31 -0
  21. package/src/e2ee/index.ts +76 -0
  22. package/src/e2ee/internal.ts +27 -0
  23. package/src/e2ee/media/media-crypto.ts +147 -0
  24. package/src/e2ee/media/media-upload.ts +90 -0
  25. package/src/e2ee/message/builders/client-payload.ts +54 -0
  26. package/src/e2ee/message/builders/consumer-application.ts +362 -0
  27. package/src/e2ee/message/builders/message-application.ts +64 -0
  28. package/src/e2ee/message/builders/message-transport.ts +84 -0
  29. package/src/e2ee/message/codecs/protobuf-codecs.ts +101 -0
  30. package/src/e2ee/message/constants.ts +4 -0
  31. package/src/e2ee/message/message-builder.ts +15 -0
  32. package/src/e2ee/message/proto/ArmadilloApplication.proto +281 -0
  33. package/src/e2ee/message/proto/ArmadilloICDC.proto +14 -0
  34. package/src/e2ee/message/proto/ConsumerApplication.proto +232 -0
  35. package/src/e2ee/message/proto/MessageApplication.proto +82 -0
  36. package/src/e2ee/message/proto/MessageTransport.proto +77 -0
  37. package/src/e2ee/message/proto/WACommon.proto +66 -0
  38. package/src/e2ee/message/proto/WAMediaTransport.proto +176 -0
  39. package/src/e2ee/message/proto/proto-writer.ts +76 -0
  40. package/src/e2ee/signal/prekey-manager.ts +140 -0
  41. package/src/e2ee/signal/signal-manager.ts +365 -0
  42. package/src/e2ee/store/device-json.ts +47 -0
  43. package/src/e2ee/store/device-repository.ts +12 -0
  44. package/src/e2ee/store/device-store.ts +538 -0
  45. package/src/e2ee/transport/binary/decoder.ts +180 -0
  46. package/src/e2ee/transport/binary/encoder.ts +143 -0
  47. package/src/e2ee/transport/binary/stanzas.ts +97 -0
  48. package/src/e2ee/transport/binary/tokens.ts +64 -0
  49. package/src/e2ee/transport/binary/wa-binary.ts +8 -0
  50. package/src/e2ee/transport/dgw/dgw-socket.ts +345 -0
  51. package/src/e2ee/transport/noise/noise-handshake.ts +417 -0
  52. package/src/e2ee/transport/noise/noise-socket.ts +230 -0
  53. package/src/index.ts +34 -0
  54. package/src/models/client.ts +26 -0
  55. package/src/models/config.ts +14 -0
  56. package/src/models/domain.ts +299 -0
  57. package/src/models/e2ee.ts +295 -0
  58. package/src/models/media.ts +41 -0
  59. package/src/models/messaging.ts +88 -0
  60. package/src/models/thread.ts +69 -0
  61. package/src/repositories/session.repository.ts +20 -0
  62. package/src/services/auth.service.ts +55 -0
  63. package/src/services/e2ee.service.ts +174 -0
  64. package/src/services/facebook-gateway.service.ts +245 -0
  65. package/src/services/icdc.service.ts +177 -0
  66. package/src/services/media.service.ts +304 -0
  67. package/src/services/messaging.service.ts +28 -0
  68. package/src/services/thread.service.ts +199 -0
  69. package/src/types/advanced-types.ts +61 -0
  70. package/src/types/fca-unofficial.d.ts +212 -0
  71. package/src/types/protocol-types.ts +43 -0
  72. package/src/utils/fca-utils.ts +30 -0
  73. package/src/utils/logger.ts +24 -0
  74. package/src/utils/mime.ts +51 -0
  75. package/tests/.env.example +87 -0
  76. package/tests/data/1x1.png +0 -0
  77. package/tests/data/example.txt +1 -0
  78. package/tests/data/file_example.mp3 +0 -0
  79. package/tests/data/file_example.mp4 +0 -0
  80. package/tests/integration.test.ts +498 -0
  81. package/tests/script/echo-e2ee.ts +174 -0
  82. package/tests/script/send-e2ee-media.ts +227 -0
  83. package/tests/script/send-e2ee-reaction.ts +108 -0
  84. package/tests/script/send-e2ee-unsend.ts +115 -0
  85. package/tests/script/send-typing.ts +107 -0
  86. package/tests/script/test-group-send.ts +105 -0
  87. package/tests/setup.ts +3 -0
  88. package/tests/types.test.ts +57 -0
  89. package/tests/unit/controllers/dgw-handler.test.ts +60 -0
  90. package/tests/unit/controllers/e2ee-handler.test.ts +293 -0
  91. package/tests/unit/controllers/event-mapper.test.ts +252 -0
  92. package/tests/unit/e2ee/application-helpers.test.ts +418 -0
  93. package/tests/unit/e2ee/device-store.test.ts +126 -0
  94. package/tests/unit/e2ee/facebook-protocol-utils.test.ts +134 -0
  95. package/tests/unit/e2ee/media-crypto.test.ts +55 -0
  96. package/tests/unit/e2ee/media-upload.test.ts +60 -0
  97. package/tests/unit/e2ee/message-builder.test.ts +42 -0
  98. package/tests/unit/e2ee/message-builders.test.ts +230 -0
  99. package/tests/unit/e2ee/noise-handshake.test.ts +260 -0
  100. package/tests/unit/e2ee/prekey-manager.test.ts +55 -0
  101. package/tests/unit/e2ee/wa-binary.test.ts +127 -0
  102. package/tests/unit/services/e2ee.service.test.ts +101 -0
  103. package/tests/unit/services/facebook-gateway.service.test.ts +138 -0
  104. package/tests/unit/services/media.service.test.ts +169 -0
  105. package/tests/unit/utils/fca-utils.test.ts +48 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,345 @@
1
+ import { EventEmitter } from "node:events";
2
+ import WebSocket from "ws";
3
+
4
+ export type DGWEndpointKind = "lightspeed" | "streamcontroller" | "realtime";
5
+
6
+ export type DGWEndpoints = Partial<Record<DGWEndpointKind, string>>;
7
+
8
+ export interface DGWConnectOptions {
9
+ endpoints: DGWEndpoints;
10
+ cookieHeader: string;
11
+ userAgent: string;
12
+ origin: string;
13
+ referer?: string;
14
+ acceptLanguage?: string;
15
+ pingIntervalMs?: number;
16
+ // Optional bootstrap params for realtime OPEN frame.
17
+ bootstrap?: {
18
+ targets?: DGWEndpointKind[];
19
+ streamId?: number;
20
+ method?: string;
21
+ docId?: string;
22
+ routingHint?: string;
23
+ body?: string;
24
+ acceptAck?: string;
25
+ referer?: string;
26
+
27
+ dataTargets?: DGWEndpointKind[];
28
+ dataPayload?: Buffer;
29
+ dataRequiresAck?: boolean;
30
+ dataAckId?: number;
31
+ };
32
+ }
33
+
34
+ interface ParsedDGWFrame {
35
+ frameType: number;
36
+ streamId?: number;
37
+ payloadLength?: number;
38
+ requiresAck?: boolean;
39
+ ackId?: number;
40
+ payload?: Buffer;
41
+ }
42
+
43
+ const FRAME_PING = 0x09;
44
+ const FRAME_PONG = 0x0a;
45
+ const FRAME_ACK = 0x0c;
46
+ const FRAME_DATA = 0x0d;
47
+ const FRAME_OPEN = 0x0f;
48
+
49
+ export class FacebookDGWSocket extends EventEmitter {
50
+ private sockets: Partial<Record<DGWEndpointKind, WebSocket>> = {};
51
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
52
+
53
+ public async connect(opts: DGWConnectOptions): Promise<void> {
54
+ const entries = Object.entries(opts.endpoints).filter(([, u]) => Boolean(u)) as Array<[DGWEndpointKind, string]>;
55
+ if (entries.length === 0) {
56
+ throw new Error("No DGW endpoint provided");
57
+ }
58
+
59
+ await Promise.all(entries.map(([kind, url]) => this.connectOne(kind, url, opts)));
60
+
61
+ if (opts.bootstrap) {
62
+ const targets = opts.bootstrap.targets ?? ["realtime", "streamcontroller"];
63
+ for (const target of targets) {
64
+ const ws = this.sockets[target];
65
+ if (!ws || ws.readyState !== WebSocket.OPEN) continue;
66
+
67
+ try {
68
+ const open = this.buildOpenFrame({
69
+ streamId: opts.bootstrap.streamId ?? 1,
70
+ method: opts.bootstrap.method ?? "FBGQLS:FRLightSpeedLiveQuery",
71
+ docId: opts.bootstrap.docId ?? "8364718423641772",
72
+ routingHint: opts.bootstrap.routingHint ?? "FRLightSpeedLiveQuery",
73
+ body: opts.bootstrap.body ?? JSON.stringify({
74
+ input_data: {
75
+ sync_params: JSON.stringify({
76
+ filter: ["lightspeed"],
77
+ }),
78
+ }
79
+ }),
80
+ acceptAck: opts.bootstrap.acceptAck ?? "RSAck",
81
+ referer: opts.bootstrap.referer ?? "https://www.facebook.com/",
82
+ });
83
+ ws.send(open);
84
+ this.emit("debug", { type: "bootstrap_open_sent", target });
85
+ } catch (err) {
86
+ this.emit("debug", { type: "bootstrap_open_failed", target, error: (err as Error).message });
87
+ }
88
+ }
89
+
90
+ if (opts.bootstrap.dataPayload && opts.bootstrap.dataPayload.length > 0) {
91
+ const dataTargets = opts.bootstrap.dataTargets ?? targets;
92
+ for (const target of dataTargets) {
93
+ const ws = this.sockets[target];
94
+ if (!ws || ws.readyState !== WebSocket.OPEN) continue;
95
+
96
+ try {
97
+ const dataFrame = this.buildDataFrame(
98
+ opts.bootstrap.streamId ?? 1,
99
+ opts.bootstrap.dataPayload,
100
+ opts.bootstrap.dataRequiresAck ?? true,
101
+ opts.bootstrap.dataAckId ?? 0,
102
+ );
103
+ ws.send(dataFrame);
104
+ this.emit("debug", {
105
+ type: "bootstrap_data_sent",
106
+ target,
107
+ payloadLen: opts.bootstrap.dataPayload.length,
108
+ requiresAck: opts.bootstrap.dataRequiresAck ?? true,
109
+ ackId: opts.bootstrap.dataAckId ?? 0,
110
+ });
111
+ } catch (err) {
112
+ this.emit("debug", { type: "bootstrap_data_failed", target, error: (err as Error).message });
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ this.startPingLoop(opts.pingIntervalMs ?? 15000);
119
+
120
+ this.emit("connected");
121
+ }
122
+
123
+ public close(): void {
124
+ this.stopPingLoop();
125
+ for (const ws of Object.values(this.sockets)) {
126
+ ws?.close();
127
+ }
128
+ this.sockets = {};
129
+ }
130
+
131
+ public sendDataFrame(target: DGWEndpointKind, streamId: number, payload: Buffer, requiresAck = true, ackId = 0): void {
132
+ const ws = this.sockets[target];
133
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
134
+ throw new Error(`Socket for ${target} is not open`);
135
+ }
136
+ const frame = this.buildDataFrame(streamId, payload, requiresAck, ackId);
137
+ ws.send(frame);
138
+ this.emit("debug", { type: "data_sent", target, streamId, payloadLen: payload.length });
139
+ }
140
+
141
+ private async connectOne(kind: DGWEndpointKind, url: string, opts: DGWConnectOptions): Promise<void> {
142
+ await new Promise<void>((resolve, reject) => {
143
+ const host = new URL(url).hostname;
144
+ const ws = new WebSocket(url, {
145
+ headers: {
146
+ Origin: opts.origin,
147
+ "User-Agent": opts.userAgent,
148
+ Cookie: opts.cookieHeader,
149
+ Referer: opts.referer ?? `${opts.origin}/`,
150
+ Host: host,
151
+ "Accept-Language": opts.acceptLanguage ?? "en-US,en;q=0.9",
152
+ },
153
+ perMessageDeflate: true,
154
+ });
155
+
156
+ ws.on("open", () => {
157
+ this.sockets[kind] = ws;
158
+ this.emit("socket_open", { kind, url });
159
+ resolve();
160
+ });
161
+
162
+ ws.on("message", (data: any, isBinary: boolean) => {
163
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
164
+ if (!isBinary || buf.length === 0) {
165
+ this.emit("frame", { kind, isBinary, text: buf.toString("utf8") });
166
+ return;
167
+ }
168
+
169
+ this.handleIncomingFrame(kind, buf);
170
+ });
171
+
172
+ ws.on("error", (err: Error) => {
173
+ this.emit("error", new Error(`DGW:${kind}] ${err.message}`));
174
+ reject(err);
175
+ });
176
+
177
+ ws.on("close", (code: number, reason: Buffer) => {
178
+ this.emit("socket_close", { kind, code, reason: reason.toString() });
179
+ });
180
+ });
181
+ }
182
+
183
+ private startPingLoop(intervalMs: number): void {
184
+ this.stopPingLoop();
185
+
186
+ this.pingTimer = setInterval(() => {
187
+ for (const [kind, ws] of Object.entries(this.sockets) as Array<[DGWEndpointKind, WebSocket | undefined]>) {
188
+ if (!ws || ws.readyState !== WebSocket.OPEN) continue;
189
+ ws.send(Buffer.from([FRAME_PING]));
190
+ this.emit("debug", { type: "ping_sent", target: kind });
191
+ }
192
+ }, intervalMs);
193
+ }
194
+
195
+ private stopPingLoop(): void {
196
+ if (this.pingTimer) {
197
+ clearInterval(this.pingTimer);
198
+ this.pingTimer = null;
199
+ }
200
+ }
201
+
202
+ private handleIncomingFrame(kind: DGWEndpointKind, buf: Buffer): void {
203
+ const parsed = this.parseFrame(buf);
204
+ if (!parsed) {
205
+ this.emit("frame", { kind, rawHex: buf.toString("hex") });
206
+ return;
207
+ }
208
+
209
+ const payloadPreview = parsed.payload ? this.tryParsePayload(parsed.payload) : null;
210
+
211
+ this.emit("frame", {
212
+ kind,
213
+ frameType: parsed.frameType,
214
+ streamId: parsed.streamId,
215
+ payloadLength: parsed.payloadLength,
216
+ requiresAck: parsed.requiresAck,
217
+ ackId: parsed.ackId,
218
+ payloadHexHead: parsed.payload?.subarray(0, 48).toString("hex"),
219
+ payloadTextHead: payloadPreview?.textHead,
220
+ payloadJson: payloadPreview?.json,
221
+ });
222
+
223
+ const ws = this.sockets[kind];
224
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
225
+
226
+ if (parsed.frameType === FRAME_PING) {
227
+ ws.send(Buffer.from([FRAME_PONG]));
228
+ return;
229
+ }
230
+
231
+ if (parsed.frameType === FRAME_DATA && parsed.requiresAck && typeof parsed.streamId === "number" && typeof parsed.ackId === "number") {
232
+ ws.send(this.buildAckFrame(parsed.streamId, parsed.ackId));
233
+ return;
234
+ }
235
+ }
236
+
237
+ private parseFrame(buf: Buffer): ParsedDGWFrame | null {
238
+ if (buf.length < 1) return null;
239
+ const frameType = buf[0] ?? 0;
240
+
241
+ if (frameType === FRAME_PING || frameType === FRAME_PONG) {
242
+ return { frameType };
243
+ }
244
+
245
+ if (frameType === 0x0e) {
246
+ this.emit("debug", { type: "frame_0e_len", len: buf.length, hex: buf.toString("hex") });
247
+ }
248
+
249
+ if ((frameType === FRAME_OPEN || frameType === FRAME_ACK || frameType === FRAME_DATA || frameType === 0x0e) && buf.length >= 6) {
250
+ const streamId = buf.readUInt16LE(1);
251
+ const payloadLength = buf.readUInt16LE(3);
252
+
253
+ if (frameType === FRAME_ACK) {
254
+ if (buf.length < 8) return null;
255
+ const ackId = buf.readUInt16LE(6);
256
+ return { frameType, streamId, payloadLength, ackId };
257
+ }
258
+
259
+ if (frameType === FRAME_DATA) {
260
+ if (buf.length < 8) return null;
261
+ const ackRaw = buf.readUInt16LE(6);
262
+ const requiresAck = (ackRaw & 0x8000) > 0;
263
+ const ackId = ackRaw & 0x7fff;
264
+ const payload = buf.subarray(8, 8 + Math.max(0, payloadLength - 2));
265
+ return { frameType, streamId, payloadLength, requiresAck, ackId, payload };
266
+ }
267
+
268
+ const payload = buf.subarray(6, 6 + payloadLength);
269
+ return { frameType, streamId, payloadLength, payload };
270
+ }
271
+
272
+ return { frameType };
273
+ }
274
+
275
+ private buildAckFrame(streamId: number, ackId: number): Buffer {
276
+ const out = Buffer.alloc(8);
277
+ out[0] = FRAME_ACK;
278
+ out.writeUInt16LE(streamId & 0xffff, 1);
279
+ out.writeUInt16LE(2, 3);
280
+ out[5] = 0;
281
+ out.writeUInt16LE(ackId & 0xffff, 6);
282
+ return out;
283
+ }
284
+
285
+ private buildDataFrame(streamId: number, payload: Buffer, requiresAck: boolean, ackId: number): Buffer {
286
+ const out = Buffer.alloc(8 + payload.length);
287
+ out[0] = FRAME_DATA;
288
+ out.writeUInt16LE(streamId & 0xffff, 1);
289
+ out.writeUInt16LE(payload.length + 2, 3);
290
+ out[5] = 0;
291
+
292
+ let ackRaw = ackId & 0x7fff;
293
+ if (requiresAck) ackRaw |= 0x8000;
294
+ out.writeUInt16LE(ackRaw, 6);
295
+
296
+ payload.copy(out, 8);
297
+ return out;
298
+ }
299
+
300
+ private buildOpenFrame(params: {
301
+ streamId: number;
302
+ method: string;
303
+ docId: string;
304
+ routingHint: string;
305
+ body: string;
306
+ acceptAck: string;
307
+ referer: string;
308
+ }): Buffer {
309
+ const jsonPayload = Buffer.from(JSON.stringify({
310
+ "x-dgw-app-XRSS-method": params.method,
311
+ "x-dgw-app-XRSS-doc_id": params.docId,
312
+ "x-dgw-app-XRSS-routing_hint": params.routingHint,
313
+ "x-dgw-app-xrs-body": params.body,
314
+ "x-dgw-app-XRS-Accept-Ack": params.acceptAck,
315
+ "x-dgw-app-XRSS-http_referer": params.referer,
316
+ }));
317
+
318
+ const out = Buffer.alloc(6 + jsonPayload.length);
319
+ out[0] = FRAME_OPEN;
320
+ out.writeUInt16LE(params.streamId & 0xffff, 1);
321
+ out.writeUInt16LE(jsonPayload.length, 3);
322
+ out[5] = 0;
323
+ jsonPayload.copy(out, 6);
324
+ return out;
325
+ }
326
+
327
+ private tryParsePayload(payload: Buffer): { textHead?: string; json?: unknown } {
328
+ // DGW DATA payloads are often mixed binary+JSON; extract the first JSON object if present.
329
+ const text = payload.toString("utf8");
330
+ const textHead = text.slice(0, 240);
331
+
332
+ const start = text.indexOf("{");
333
+ const end = text.lastIndexOf("}");
334
+ if (start >= 0 && end > start) {
335
+ const jsonStr = text.slice(start, end + 1);
336
+ try {
337
+ return { textHead, json: JSON.parse(jsonStr) };
338
+ } catch {
339
+ return { textHead };
340
+ }
341
+ }
342
+
343
+ return { textHead };
344
+ }
345
+ }