fb-messenger-e2ee 0.1.0 → 0.1.2

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 (110) hide show
  1. package/README.md +34 -6
  2. package/dist/index.cjs +8811 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +885 -0
  5. package/dist/index.d.ts +885 -0
  6. package/dist/index.js +8843 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +19 -4
  9. package/bun.lock +0 -1014
  10. package/examples/basic-usage.ts +0 -52
  11. package/jest.config.ts +0 -18
  12. package/src/config/env.ts +0 -15
  13. package/src/controllers/client.controller.ts +0 -805
  14. package/src/controllers/dgw-handler.ts +0 -171
  15. package/src/controllers/e2ee-handler.ts +0 -832
  16. package/src/controllers/event-mapper.ts +0 -327
  17. package/src/core/client.ts +0 -151
  18. package/src/e2ee/application/e2ee-client.ts +0 -376
  19. package/src/e2ee/application/fanout-planner.ts +0 -79
  20. package/src/e2ee/application/outbound-message-cache.ts +0 -58
  21. package/src/e2ee/application/prekey-maintenance.ts +0 -55
  22. package/src/e2ee/application/retry-manager.ts +0 -156
  23. package/src/e2ee/facebook/facebook-protocol-utils.ts +0 -230
  24. package/src/e2ee/facebook/icdc-payload.ts +0 -31
  25. package/src/e2ee/index.ts +0 -76
  26. package/src/e2ee/internal.ts +0 -27
  27. package/src/e2ee/media/media-crypto.ts +0 -147
  28. package/src/e2ee/media/media-upload.ts +0 -90
  29. package/src/e2ee/message/builders/client-payload.ts +0 -54
  30. package/src/e2ee/message/builders/consumer-application.ts +0 -362
  31. package/src/e2ee/message/builders/message-application.ts +0 -64
  32. package/src/e2ee/message/builders/message-transport.ts +0 -84
  33. package/src/e2ee/message/codecs/protobuf-codecs.ts +0 -101
  34. package/src/e2ee/message/constants.ts +0 -4
  35. package/src/e2ee/message/message-builder.ts +0 -15
  36. package/src/e2ee/signal/prekey-manager.ts +0 -140
  37. package/src/e2ee/signal/signal-manager.ts +0 -365
  38. package/src/e2ee/store/device-json.ts +0 -47
  39. package/src/e2ee/store/device-repository.ts +0 -12
  40. package/src/e2ee/store/device-store.ts +0 -538
  41. package/src/e2ee/transport/binary/decoder.ts +0 -180
  42. package/src/e2ee/transport/binary/encoder.ts +0 -143
  43. package/src/e2ee/transport/binary/stanzas.ts +0 -97
  44. package/src/e2ee/transport/binary/tokens.ts +0 -64
  45. package/src/e2ee/transport/binary/wa-binary.ts +0 -8
  46. package/src/e2ee/transport/dgw/dgw-socket.ts +0 -345
  47. package/src/e2ee/transport/noise/noise-handshake.ts +0 -417
  48. package/src/e2ee/transport/noise/noise-socket.ts +0 -230
  49. package/src/index.ts +0 -34
  50. package/src/models/client.ts +0 -26
  51. package/src/models/config.ts +0 -14
  52. package/src/models/domain.ts +0 -299
  53. package/src/models/e2ee.ts +0 -295
  54. package/src/models/media.ts +0 -41
  55. package/src/models/messaging.ts +0 -88
  56. package/src/models/thread.ts +0 -69
  57. package/src/repositories/session.repository.ts +0 -20
  58. package/src/services/auth.service.ts +0 -55
  59. package/src/services/e2ee.service.ts +0 -174
  60. package/src/services/facebook-gateway.service.ts +0 -245
  61. package/src/services/icdc.service.ts +0 -177
  62. package/src/services/media.service.ts +0 -304
  63. package/src/services/messaging.service.ts +0 -28
  64. package/src/services/thread.service.ts +0 -199
  65. package/src/types/advanced-types.ts +0 -61
  66. package/src/types/fca-unofficial.d.ts +0 -212
  67. package/src/types/protocol-types.ts +0 -43
  68. package/src/utils/fca-utils.ts +0 -30
  69. package/src/utils/logger.ts +0 -24
  70. package/src/utils/mime.ts +0 -51
  71. package/tests/.env.example +0 -87
  72. package/tests/data/1x1.png +0 -0
  73. package/tests/data/example.txt +0 -1
  74. package/tests/data/file_example.mp3 +0 -0
  75. package/tests/data/file_example.mp4 +0 -0
  76. package/tests/integration.test.ts +0 -498
  77. package/tests/script/echo-e2ee.ts +0 -174
  78. package/tests/script/send-e2ee-media.ts +0 -227
  79. package/tests/script/send-e2ee-reaction.ts +0 -108
  80. package/tests/script/send-e2ee-unsend.ts +0 -115
  81. package/tests/script/send-typing.ts +0 -107
  82. package/tests/script/test-group-send.ts +0 -105
  83. package/tests/setup.ts +0 -3
  84. package/tests/types.test.ts +0 -57
  85. package/tests/unit/controllers/dgw-handler.test.ts +0 -60
  86. package/tests/unit/controllers/e2ee-handler.test.ts +0 -293
  87. package/tests/unit/controllers/event-mapper.test.ts +0 -252
  88. package/tests/unit/e2ee/application-helpers.test.ts +0 -418
  89. package/tests/unit/e2ee/device-store.test.ts +0 -126
  90. package/tests/unit/e2ee/facebook-protocol-utils.test.ts +0 -134
  91. package/tests/unit/e2ee/media-crypto.test.ts +0 -55
  92. package/tests/unit/e2ee/media-upload.test.ts +0 -60
  93. package/tests/unit/e2ee/message-builder.test.ts +0 -42
  94. package/tests/unit/e2ee/message-builders.test.ts +0 -230
  95. package/tests/unit/e2ee/noise-handshake.test.ts +0 -260
  96. package/tests/unit/e2ee/prekey-manager.test.ts +0 -55
  97. package/tests/unit/e2ee/wa-binary.test.ts +0 -127
  98. package/tests/unit/services/e2ee.service.test.ts +0 -101
  99. package/tests/unit/services/facebook-gateway.service.test.ts +0 -138
  100. package/tests/unit/services/media.service.test.ts +0 -169
  101. package/tests/unit/utils/fca-utils.test.ts +0 -48
  102. package/tsconfig.json +0 -19
  103. /package/{src/e2ee/message/proto → proto}/ArmadilloApplication.proto +0 -0
  104. /package/{src/e2ee/message/proto → proto}/ArmadilloICDC.proto +0 -0
  105. /package/{src/e2ee/message/proto → proto}/ConsumerApplication.proto +0 -0
  106. /package/{src/e2ee/message/proto → proto}/MessageApplication.proto +0 -0
  107. /package/{src/e2ee/message/proto → proto}/MessageTransport.proto +0 -0
  108. /package/{src/e2ee/message/proto → proto}/WACommon.proto +0 -0
  109. /package/{src/e2ee/message/proto → proto}/WAMediaTransport.proto +0 -0
  110. /package/{src/e2ee/message/proto → proto}/proto-writer.ts +0 -0
@@ -1,418 +0,0 @@
1
- import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2
- import {
3
- buildParticipantListHash,
4
- normalizeDMThreadToJid,
5
- parseMessengerJid,
6
- sameMessengerDevice,
7
- sameMessengerUser,
8
- toADString,
9
- toBareMessengerJid,
10
- uniqueJids,
11
- } from "../../../src/e2ee/application/fanout-planner.ts";
12
- import { OutboundMessageCache, type RecentE2EEOutgoing } from "../../../src/e2ee/application/outbound-message-cache.ts";
13
- import { PreKeyMaintenance } from "../../../src/e2ee/application/prekey-maintenance.ts";
14
- import { E2EERetryManager } from "../../../src/e2ee/application/retry-manager.ts";
15
- import { unmarshal, type Node } from "../../../src/e2ee/transport/binary/wa-binary.ts";
16
-
17
- const makeOutgoing = (overrides: Partial<RecentE2EEOutgoing> = {}): RecentE2EEOutgoing => ({
18
- kind: "dm",
19
- chatJid: "200.0@msgr",
20
- messageId: "m1",
21
- messageType: "text",
22
- messageApp: Buffer.from("message-app"),
23
- frankingTag: Buffer.from("franking-tag"),
24
- createdAtMs: 1_000,
25
- ...overrides,
26
- });
27
-
28
- const findChild = (node: Node, tag: string): Node | undefined => {
29
- const children = Array.isArray(node.content) ? node.content as Node[] : [];
30
- return children.find((child) => child.tag === tag);
31
- };
32
-
33
- describe("E2EE application helpers", () => {
34
- let logSpy: jest.SpiedFunction<typeof console.log>;
35
-
36
- beforeEach(() => {
37
- logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
38
- });
39
-
40
- afterEach(() => {
41
- logSpy.mockRestore();
42
- });
43
-
44
- describe("fanout-planner", () => {
45
- it("normalizes Messenger device JIDs to stable bare DM chat JIDs", () => {
46
- expect(normalizeDMThreadToJid("100042415119261")).toBe("100042415119261.0@msgr");
47
- expect(normalizeDMThreadToJid("100042415119261.160@msgr")).toBe("100042415119261.0@msgr");
48
- expect(toBareMessengerJid("100042415119261:160@msgr")).toBe("100042415119261.0@msgr");
49
- });
50
-
51
- it("parses dot, colon, and AD-style Messenger device identifiers", () => {
52
- expect(parseMessengerJid("100.160@msgr")).toMatchObject({ user: "100", device: 160, server: "msgr" });
53
- expect(parseMessengerJid("100:160@msgr")).toMatchObject({ user: "100", device: 160, server: "msgr" });
54
- expect(parseMessengerJid("100.0:160@msgr")).toMatchObject({ user: "100", device: 160, server: "msgr" });
55
- expect(toADString("100.160@msgr")).toBe("100.0:160@msgr");
56
- });
57
-
58
- it("deduplicates devices by Messenger user and device while preserving first occurrence", () => {
59
- expect(uniqueJids(["100.1@msgr", "100:1@msgr", "100.2@msgr", "200.1@msgr"])).toEqual([
60
- "100.1@msgr",
61
- "100.2@msgr",
62
- "200.1@msgr",
63
- ]);
64
- expect(sameMessengerUser("100.1@msgr", "100.2@msgr")).toBe(true);
65
- expect(sameMessengerDevice("100.1@msgr", "100:1@msgr")).toBe(true);
66
- expect(sameMessengerDevice("100.1@msgr", "100.2@msgr")).toBe(false);
67
- });
68
-
69
- it("builds participant hashes independent of input order and JID separator style", () => {
70
- const hashA = buildParticipantListHash(["200.2@msgr", "100.1@msgr"]);
71
- const hashB = buildParticipantListHash(["100:1@msgr", "200:2@msgr"]);
72
-
73
- expect(hashA).toBe(hashB);
74
- expect(hashA).toMatch(/^2:[A-Za-z0-9+/]{8}$/);
75
- });
76
- });
77
-
78
- describe("OutboundMessageCache", () => {
79
- it("returns remembered records and prunes expired entries", () => {
80
- const nowMs = Date.now();
81
- const cache = new OutboundMessageCache({ ttlMs: 100, maxEntries: 10 });
82
- cache.remember(makeOutgoing({ messageId: "fresh", createdAtMs: nowMs }));
83
- cache.remember(makeOutgoing({ messageId: "old", createdAtMs: nowMs - 200 }));
84
-
85
- cache.prune(nowMs + 1);
86
- expect(cache.get("fresh")?.messageId).toBe("fresh");
87
- cache.prune(nowMs + 1);
88
- expect(cache.get("old")).toBeUndefined();
89
- });
90
-
91
- it("keeps only the newest records when maxEntries is exceeded", () => {
92
- const nowMs = Date.now();
93
- const cache = new OutboundMessageCache({ ttlMs: 10_000, maxEntries: 2 });
94
- cache.remember(makeOutgoing({ messageId: "m1", createdAtMs: nowMs }));
95
- cache.remember(makeOutgoing({ messageId: "m2", createdAtMs: nowMs }));
96
- cache.remember(makeOutgoing({ messageId: "m3", createdAtMs: nowMs }));
97
-
98
- expect(cache.get("m1")).toBeUndefined();
99
- expect(cache.get("m2")?.messageId).toBe("m2");
100
- expect(cache.get("m3")?.messageId).toBe("m3");
101
-
102
- cache.clear();
103
- expect(cache.get("m2")).toBeUndefined();
104
- });
105
- });
106
-
107
- describe("PreKeyMaintenance", () => {
108
- const oldEnv = process.env;
109
-
110
- beforeEach(() => {
111
- process.env = { ...oldEnv, FB_E2EE_PREKEY_MIN_COUNT: "5", FB_E2EE_PREKEY_UPLOAD_COUNT: "9" };
112
- });
113
-
114
- afterEach(() => {
115
- process.env = oldEnv;
116
- jest.useRealTimers();
117
- });
118
-
119
- it("uploads fresh prekeys when server count is below threshold", async () => {
120
- const uploadPreKeys = jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined);
121
- const maintenance = new PreKeyMaintenance({
122
- getSocket: () => ({}),
123
- getStore: () => ({}),
124
- getServerPreKeyCount: jest.fn<() => Promise<number>>().mockResolvedValue(4),
125
- uploadPreKeys,
126
- });
127
-
128
- await maintenance.sync("test");
129
-
130
- expect(uploadPreKeys).toHaveBeenCalledWith(9);
131
- });
132
-
133
- it("does not upload when disconnected or when server count is healthy", async () => {
134
- const uploadPreKeys = jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined);
135
- const disconnected = new PreKeyMaintenance({
136
- getSocket: () => null,
137
- getStore: () => ({}),
138
- getServerPreKeyCount: jest.fn<() => Promise<number>>().mockResolvedValue(0),
139
- uploadPreKeys,
140
- });
141
- await disconnected.sync("disconnected");
142
-
143
- const healthy = new PreKeyMaintenance({
144
- getSocket: () => ({}),
145
- getStore: () => ({}),
146
- getServerPreKeyCount: jest.fn<() => Promise<number>>().mockResolvedValue(5),
147
- uploadPreKeys,
148
- });
149
- await healthy.sync("healthy");
150
-
151
- expect(uploadPreKeys).not.toHaveBeenCalled();
152
- });
153
-
154
-
155
- it("starts periodic sync and stop cancels the interval", async () => {
156
- jest.useFakeTimers();
157
- process.env.FB_E2EE_PREKEY_SYNC_INTERVAL_MS = "100";
158
- const getServerPreKeyCount = jest.fn<() => Promise<number>>().mockResolvedValue(5);
159
- const maintenance = new PreKeyMaintenance({
160
- getSocket: () => ({}),
161
- getStore: () => ({}),
162
- getServerPreKeyCount,
163
- uploadPreKeys: jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined),
164
- });
165
- try {
166
- maintenance.start();
167
- await jest.advanceTimersByTimeAsync(100);
168
- expect(getServerPreKeyCount).toHaveBeenCalledTimes(1);
169
-
170
- maintenance.stop();
171
- await jest.advanceTimersByTimeAsync(100);
172
- expect(getServerPreKeyCount).toHaveBeenCalledTimes(1);
173
- } finally {
174
- maintenance.stop();
175
- }
176
- });
177
-
178
- it("does not start periodic sync when interval is disabled", async () => {
179
- jest.useFakeTimers();
180
- process.env.FB_E2EE_PREKEY_SYNC_INTERVAL_MS = "0";
181
- const getServerPreKeyCount = jest.fn<() => Promise<number>>().mockResolvedValue(0);
182
- const maintenance = new PreKeyMaintenance({
183
- getSocket: () => ({}),
184
- getStore: () => ({}),
185
- getServerPreKeyCount,
186
- uploadPreKeys: jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined),
187
- });
188
-
189
- maintenance.start();
190
- await jest.advanceTimersByTimeAsync(1_000);
191
-
192
- expect(getServerPreKeyCount).not.toHaveBeenCalled();
193
- });
194
-
195
- it("swallows sync errors after logging them", async () => {
196
- const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
197
- const maintenance = new PreKeyMaintenance({
198
- getSocket: () => ({}),
199
- getStore: () => ({}),
200
- getServerPreKeyCount: jest.fn<() => Promise<number>>().mockRejectedValue(new Error("count failed")),
201
- uploadPreKeys: jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined),
202
- });
203
-
204
- try {
205
- await expect(maintenance.sync("manual")).resolves.toBeUndefined();
206
- expect(errorSpy).toHaveBeenCalledWith(
207
- "PreKeyMaintenance",
208
- expect.stringContaining("Prekey sync failed (manual)"),
209
- expect.any(Error),
210
- );
211
- } finally {
212
- errorSpy.mockRestore();
213
- }
214
- });
215
-
216
- });
217
-
218
- describe("E2EERetryManager", () => {
219
- it("re-encrypts cached DM messages to the requesting device", async () => {
220
- const cache = new OutboundMessageCache({ ttlMs: 10_000 });
221
- cache.remember(makeOutgoing({ messageId: "dm-1", chatJid: "200.0@msgr", createdAtMs: Date.now() }));
222
- const socket = { sendFrame: jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined) };
223
- const client = {
224
- hasSession: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
225
- encryptMessageAppForDevice: jest.fn<(recipientJid: string, selfJid: string, messageApp: Buffer, opts?: any) => Promise<{ type: "msg"; ciphertext: Buffer }>>()
226
- .mockResolvedValue({ type: "msg", ciphertext: Buffer.from("cipher") }),
227
- };
228
- const manager = new E2EERetryManager({
229
- cache,
230
- getClient: () => client as any,
231
- getSocket: () => socket as any,
232
- getSelfJid: () => "100.5@msgr",
233
- getPreKeyBundle: jest.fn() as any,
234
- });
235
-
236
- await manager.handleReceipt({
237
- tag: "receipt",
238
- attrs: { id: "dm-1", from: "200.9@msgr" },
239
- content: [{ tag: "retry", attrs: { id: "dm-1", count: "2", t: "123" } }],
240
- } as any);
241
-
242
- expect(client.encryptMessageAppForDevice).toHaveBeenCalledWith(
243
- "200.9@msgr",
244
- "100.5@msgr",
245
- Buffer.from("message-app"),
246
- { dsm: undefined },
247
- );
248
- expect(socket.sendFrame).toHaveBeenCalledTimes(1);
249
- const sent = unmarshal((socket.sendFrame as any).mock.calls[0][0]);
250
- expect(sent.attrs).toMatchObject({ to: "200.0@msgr", id: "dm-1", t: "123", device_fanout: "false" });
251
- expect(findChild(sent, "enc")?.attrs).toMatchObject({ type: "msg", count: "2" });
252
- });
253
-
254
- it("resends group messages with a fresh SKDM and backup directive", async () => {
255
- const cache = new OutboundMessageCache({ ttlMs: 10_000 });
256
- cache.remember(makeOutgoing({ kind: "group", messageId: "g-1", chatJid: "180@g.us", createdAtMs: Date.now() }));
257
- const socket = { sendFrame: jest.fn<(count: number) => Promise<void>>().mockResolvedValue(undefined) };
258
- const skdm = { groupId: "180@g.us", skdmBytes: Buffer.from("skdm"), distributionId: "dist" };
259
- const client = {
260
- hasSession: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
261
- createSenderKeyDistributionPayload: jest.fn<(groupJid: string, selfJid: string) => Promise<typeof skdm>>().mockResolvedValue(skdm),
262
- encryptMessageAppForDevice: jest.fn<(recipientJid: string, selfJid: string, messageApp: Buffer, opts?: any) => Promise<{ type: "pkmsg"; ciphertext: Buffer }>>()
263
- .mockResolvedValue({ type: "pkmsg", ciphertext: Buffer.from("cipher") }),
264
- };
265
- const manager = new E2EERetryManager({
266
- cache,
267
- getClient: () => client as any,
268
- getSocket: () => socket as any,
269
- getSelfJid: () => "100.5@msgr",
270
- getPreKeyBundle: jest.fn() as any,
271
- });
272
-
273
- await manager.handleReceipt({
274
- tag: "receipt",
275
- attrs: { id: "g-1", participant: "200.9@msgr" },
276
- content: [{ tag: "retry", attrs: { id: "g-1", count: "1" } }],
277
- } as any);
278
-
279
- expect(client.createSenderKeyDistributionPayload).toHaveBeenCalledWith("180@g.us", "100.5@msgr");
280
- expect(client.encryptMessageAppForDevice).toHaveBeenCalledWith(
281
- "200.9@msgr",
282
- "100.5@msgr",
283
- Buffer.from("message-app"),
284
- { skdm, backupDirective: { messageId: "g-1", actionType: "UPSERT" } },
285
- );
286
- const sent = unmarshal((socket.sendFrame as any).mock.calls[0][0]);
287
- expect(sent.attrs).toMatchObject({ to: "180@g.us", participant: "200.9@msgr", id: "g-1" });
288
- expect(findChild(sent, "enc")?.attrs).toMatchObject({ type: "pkmsg", count: "1" });
289
- });
290
-
291
- it("establishes a session from retry receipt keys before resending", async () => {
292
- const cache = new OutboundMessageCache({ ttlMs: 10_000 });
293
- cache.remember(makeOutgoing({ messageId: "dm-keys", chatJid: "200.0@msgr", createdAtMs: Date.now() }));
294
- const socket = { sendFrame: jest.fn<(frame: Buffer) => Promise<void>>().mockResolvedValue(undefined) };
295
- const client = {
296
- establishSession: jest.fn<(jid: string, bundle: any) => Promise<void>>().mockResolvedValue(undefined),
297
- hasSession: jest.fn<() => Promise<boolean>>().mockResolvedValue(false),
298
- encryptMessageAppForDevice: jest.fn<(recipientJid: string, selfJid: string, messageApp: Buffer, opts?: any) => Promise<{ type: "msg"; ciphertext: Buffer }>>()
299
- .mockResolvedValue({ type: "msg", ciphertext: Buffer.from("cipher") }),
300
- };
301
- const getPreKeyBundle = jest.fn<() => Promise<any>>().mockResolvedValue({});
302
- const manager = new E2EERetryManager({
303
- cache,
304
- getClient: () => client as any,
305
- getSocket: () => socket as any,
306
- getSelfJid: () => "100.5@msgr",
307
- getPreKeyBundle,
308
- });
309
- const registration = Buffer.alloc(4);
310
- registration.writeUInt32BE(0x01020304);
311
-
312
- await manager.handleReceipt({
313
- tag: "receipt",
314
- attrs: { id: "dm-keys", from: "200.9@msgr" },
315
- content: [
316
- { tag: "retry", attrs: { id: "dm-keys", count: "1" } },
317
- { tag: "registration", attrs: {}, content: registration },
318
- {
319
- tag: "keys",
320
- attrs: {},
321
- content: [
322
- { tag: "identity", attrs: {}, content: Buffer.alloc(32, 1) },
323
- { tag: "key", attrs: {}, content: [
324
- { tag: "id", attrs: {}, content: Buffer.from([0, 0, 7]) },
325
- { tag: "value", attrs: {}, content: Buffer.alloc(32, 2) },
326
- ] },
327
- { tag: "skey", attrs: {}, content: [
328
- { tag: "id", attrs: {}, content: Buffer.from([0, 0, 8]) },
329
- { tag: "value", attrs: {}, content: Buffer.alloc(32, 3) },
330
- { tag: "signature", attrs: {}, content: Buffer.alloc(64, 4) },
331
- ] },
332
- ],
333
- },
334
- ],
335
- } as any);
336
-
337
- expect(client.establishSession).toHaveBeenCalledWith("200.9@msgr", {
338
- registrationId: 0x01020304,
339
- deviceId: 9,
340
- identityKey: Buffer.concat([Buffer.from([5]), Buffer.alloc(32, 1)]),
341
- signedPreKey: {
342
- keyId: 8,
343
- publicKey: Buffer.concat([Buffer.from([5]), Buffer.alloc(32, 3)]),
344
- signature: Buffer.alloc(64, 4),
345
- },
346
- preKey: {
347
- keyId: 7,
348
- publicKey: Buffer.concat([Buffer.from([5]), Buffer.alloc(32, 2)]),
349
- },
350
- });
351
- expect(client.hasSession).not.toHaveBeenCalled();
352
- expect(getPreKeyBundle).not.toHaveBeenCalled();
353
- expect(socket.sendFrame).toHaveBeenCalledTimes(1);
354
- });
355
-
356
- it("fetches a prekey bundle when no retry keys and no session exist", async () => {
357
- const cache = new OutboundMessageCache({ ttlMs: 10_000 });
358
- cache.remember(makeOutgoing({ messageId: "dm-fetch", chatJid: "200.0@msgr", createdAtMs: Date.now() }));
359
- const socket = { sendFrame: jest.fn<(frame: Buffer) => Promise<void>>().mockResolvedValue(undefined) };
360
- const bundle = { registrationId: 1, deviceId: 9, identityKey: Buffer.alloc(33), signedPreKey: { keyId: 1, publicKey: Buffer.alloc(33), signature: Buffer.alloc(64) } };
361
- const client = {
362
- establishSession: jest.fn<(jid: string, bundle: any) => Promise<void>>().mockResolvedValue(undefined),
363
- hasSession: jest.fn<() => Promise<boolean>>().mockResolvedValue(false),
364
- encryptMessageAppForDevice: jest.fn<(recipientJid: string, selfJid: string, messageApp: Buffer, opts?: any) => Promise<{ type: "msg"; ciphertext: Buffer }>>()
365
- .mockResolvedValue({ type: "msg", ciphertext: Buffer.from("cipher") }),
366
- };
367
- const getPreKeyBundle = jest.fn<(jid: string) => Promise<any>>().mockResolvedValue(bundle);
368
- const manager = new E2EERetryManager({
369
- cache,
370
- getClient: () => client as any,
371
- getSocket: () => socket as any,
372
- getSelfJid: () => "100.5@msgr",
373
- getPreKeyBundle,
374
- });
375
-
376
- await manager.handleReceipt({
377
- tag: "receipt",
378
- attrs: { id: "dm-fetch", from: "200.9@msgr" },
379
- content: [{ tag: "retry", attrs: { id: "dm-fetch", count: "1" } }],
380
- } as any);
381
-
382
- expect(getPreKeyBundle).toHaveBeenCalledWith("200.9@msgr");
383
- expect(client.establishSession).toHaveBeenCalledWith("200.9@msgr", bundle);
384
- });
385
-
386
- it("ignores retry receipts at or above the retry cap", async () => {
387
- const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
388
- const cache = new OutboundMessageCache({ ttlMs: 10_000 });
389
- cache.remember(makeOutgoing({ messageId: "dm-cap", createdAtMs: Date.now() }));
390
- const socket = { sendFrame: jest.fn<(frame: Buffer) => Promise<void>>().mockResolvedValue(undefined) };
391
- const client = {
392
- hasSession: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
393
- encryptMessageAppForDevice: jest.fn(),
394
- };
395
- const manager = new E2EERetryManager({
396
- cache,
397
- getClient: () => client as any,
398
- getSocket: () => socket as any,
399
- getSelfJid: () => "100.5@msgr",
400
- getPreKeyBundle: jest.fn() as any,
401
- });
402
-
403
- try {
404
- await manager.handleReceipt({
405
- tag: "receipt",
406
- attrs: { id: "dm-cap", from: "200.9@msgr" },
407
- content: [{ tag: "retry", attrs: { id: "dm-cap", count: "10" } }],
408
- } as any);
409
-
410
- expect(client.encryptMessageAppForDevice).not.toHaveBeenCalled();
411
- expect(socket.sendFrame).not.toHaveBeenCalled();
412
- } finally {
413
- warnSpy.mockRestore();
414
- }
415
- });
416
-
417
- });
418
- });
@@ -1,126 +0,0 @@
1
- import { DeviceStore } from "../../../src/e2ee/store/device-store.ts";
2
- import { Direction, PrivateKey, ProtocolAddress } from "@signalapp/libsignal-client";
3
- import * as fs from "node:fs";
4
- import * as path from "node:path";
5
- import * as os from "node:os";
6
-
7
- describe("DeviceStore", () => {
8
- let store: DeviceStore;
9
- let testPath: string;
10
-
11
- beforeEach(async () => {
12
- testPath = path.join(os.tmpdir(), `device-${Math.random().toString(36).slice(2)}.json`);
13
- store = await DeviceStore.fromFile(testPath);
14
- });
15
-
16
- afterEach(() => {
17
- if (fs.existsSync(testPath)) {
18
- fs.unlinkSync(testPath);
19
- }
20
- });
21
-
22
- describe("serialization", () => {
23
- it("should generate a new device if file missing", () => {
24
- expect(store.registrationId).toBeGreaterThan(0);
25
- expect(store.noiseKeyPriv).toBeDefined();
26
- expect(store.toJSON().schema_version).toBe(1);
27
- });
28
-
29
- it("should export to JSON correctly", () => {
30
- const json = store.toJSON();
31
- expect(json.registration_id).toBe(store.registrationId);
32
- expect(json.noise_key_priv).toBe(store.noiseKeyPriv.toString("base64"));
33
- expect(json.identities).toEqual({});
34
- expect(json.sessions).toEqual({});
35
- expect(json.sender_keys).toEqual({});
36
- });
37
-
38
- it("should save and reload from file without rotating identity", async () => {
39
- const regId = store.registrationId;
40
- const identity = store.getIdentityPrivateKey().toString("base64");
41
- (store as any).saveToFile();
42
-
43
- const newStore = await DeviceStore.fromFile(testPath);
44
- expect(newStore.registrationId).toBe(regId);
45
- expect(newStore.getIdentityPrivateKey().toString("base64")).toBe(identity);
46
- });
47
-
48
- it("should load old JSON through migration defaults", async () => {
49
- const oldJson: any = store.toJSON();
50
- delete oldJson.schema_version;
51
- delete oldJson.identities;
52
- delete oldJson.sessions;
53
- delete oldJson.pre_keys;
54
- delete oldJson.sender_keys;
55
- delete oldJson.signed_pre_keys;
56
- delete oldJson.next_pre_key_id;
57
-
58
- const loaded = await DeviceStore.fromData(JSON.stringify(oldJson));
59
- expect(loaded.toJSON()).toMatchObject({
60
- schema_version: 1,
61
- next_pre_key_id: 1,
62
- identities: {},
63
- sessions: {},
64
- pre_keys: {},
65
- sender_keys: {},
66
- signed_pre_keys: {},
67
- });
68
- });
69
- });
70
-
71
- describe("Messenger JID persistence", () => {
72
- it("should parse dot and colon device JIDs and trigger onDataChanged only on changes", async () => {
73
- const changes: string[] = [];
74
- const memoryStore = await DeviceStore.fromData(store.getData(), json => changes.push(json));
75
-
76
- memoryStore.setJIDs("100042415119261.160@msgr", "");
77
- memoryStore.setJIDs("100042415119261.160@msgr", "");
78
- expect(memoryStore.jidUser).toBe("100042415119261");
79
- expect(memoryStore.jidDevice).toBe(160);
80
- expect(changes).toHaveLength(1);
81
-
82
- memoryStore.setJIDs("100042415119261:161@msgr", "");
83
- expect(memoryStore.jidDevice).toBe(161);
84
- expect(changes).toHaveLength(2);
85
- expect(JSON.parse(changes.at(-1)!).jid_device).toBe(161);
86
- });
87
-
88
- it("should ignore invalid non-Messenger JIDs", () => {
89
- store.setJIDs("100@s.whatsapp.net", "");
90
- expect(store.jidUser).toBeUndefined();
91
- expect(store.jidDevice).toBeUndefined();
92
- });
93
- });
94
-
95
- describe("identity and in-memory protocol maps", () => {
96
- it("should save identities, detect changes, and enforce trust-on-first-use", async () => {
97
- const address = ProtocolAddress.new("1001", 2);
98
- const keyA = PrivateKey.generate().getPublicKey();
99
- const keyB = PrivateKey.generate().getPublicKey();
100
-
101
- await expect(store.isTrustedIdentity(address, keyA, Direction.Sending)).resolves.toBe(true);
102
- await expect(store.saveIdentity(address, keyA)).resolves.toBe(true);
103
- await expect(store.saveIdentity(address, keyA)).resolves.toBe(false);
104
- await expect(store.isTrustedIdentity(address, keyA, Direction.Sending)).resolves.toBe(true);
105
- await expect(store.isTrustedIdentity(address, keyB, Direction.Sending)).resolves.toBe(false);
106
- store.autoTrust = true;
107
- await expect(store.isTrustedIdentity(address, keyB, Direction.Sending)).resolves.toBe(true);
108
- });
109
-
110
- it("should report existing sessions without deserializing unrelated addresses", () => {
111
- (store as any).sessions.set("1001.2", Buffer.from("fake-session-record"));
112
-
113
- expect(store.hasSession("1001.2")).toBe(true);
114
- expect(store.hasSession("1001.3")).toBe(false);
115
- });
116
-
117
- it("should list sender-key distribution IDs for one sender only", () => {
118
- const sender = ProtocolAddress.new("1001", 2);
119
- (store as any).senderKeys.set("1001.2::dist-a", Buffer.from("a"));
120
- (store as any).senderKeys.set("1001.2::dist-b", Buffer.from("b"));
121
- (store as any).senderKeys.set("1001.3::dist-c", Buffer.from("c"));
122
-
123
- expect(store.listSenderKeyDistributionIds(sender).sort()).toEqual(["dist-a", "dist-b"]);
124
- });
125
- });
126
- });
@@ -1,134 +0,0 @@
1
- import {
2
- decodeVarint,
3
- encodeVarint,
4
- parseFBProtobufSKDM,
5
- parseFBProtobufSKMSG,
6
- stableDistributionId,
7
- uuidParse,
8
- uuidStringify,
9
- wrapAsSignalSKMSG,
10
- } from "../../../src/e2ee/facebook/facebook-protocol-utils.ts";
11
-
12
- const fieldVarint = (field: number, value: number) => Buffer.concat([
13
- encodeVarint((field << 3) | 0),
14
- encodeVarint(value),
15
- ]);
16
-
17
- const fieldBytes = (field: number, value: Buffer) => Buffer.concat([
18
- encodeVarint((field << 3) | 2),
19
- encodeVarint(value.length),
20
- value,
21
- ]);
22
-
23
- describe("facebook-protocol-utils", () => {
24
- describe("varint", () => {
25
- it.each([0, 1, 127, 128, 150, 255, 16_384, 1_234_567, 0xffffffff])(
26
- "round-trips %i",
27
- (value) => {
28
- const buf = encodeVarint(value);
29
- const decoded = decodeVarint(Buffer.concat([Buffer.from([0xaa]), buf]), 1);
30
- expect(decoded.value).toBe(value);
31
- expect(decoded.length).toBe(buf.length);
32
- },
33
- );
34
-
35
- it("should throw on unexpected EOF", () => {
36
- const buf = Buffer.from([0x80, 0x80]);
37
- expect(() => decodeVarint(buf, 0)).toThrow("Unexpected EOF in varint");
38
- });
39
-
40
- it("should throw on overlong varints", () => {
41
- expect(() => decodeVarint(Buffer.alloc(11, 0x80), 0)).toThrow("Varint too long");
42
- });
43
- });
44
-
45
- describe("uuid", () => {
46
- it("should stringify and parse UUIDs correctly", () => {
47
- const original = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
48
- const buf = uuidParse(original);
49
- expect(buf.length).toBe(16);
50
- const str = uuidStringify(buf);
51
- expect(str).toBe(original);
52
- });
53
-
54
- it("stableDistributionId should be deterministic and look like a v4 UUID", () => {
55
- const group = "12345@g.us";
56
- const sender = "67890@s.whatsapp.net";
57
- const id1 = stableDistributionId(group, sender);
58
- const id2 = stableDistributionId(group, sender);
59
- const other = stableDistributionId(group, "11111@s.whatsapp.net");
60
-
61
- expect(id1).toBe(id2);
62
- expect(id1).not.toBe(other);
63
- expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
64
- });
65
- });
66
-
67
- describe("Facebook protobuf SKMSG/SKDM", () => {
68
- it("parses SKMSG and skips unknown varint/bytes fields", () => {
69
- const ciphertext = Buffer.from("ciphertext");
70
- const encoded = Buffer.concat([
71
- fieldVarint(9, 1),
72
- fieldBytes(10, Buffer.from("skip")),
73
- fieldVarint(1, 42),
74
- fieldVarint(2, 7),
75
- fieldBytes(3, ciphertext),
76
- ]);
77
-
78
- expect(parseFBProtobufSKMSG(encoded)).toEqual({
79
- id: 42,
80
- iteration: 7,
81
- ciphertext,
82
- });
83
- });
84
-
85
- it("returns null for malformed SKMSG length prefixes", () => {
86
- expect(parseFBProtobufSKMSG(Buffer.from([0x1a, 0x80]))).toBeNull();
87
- });
88
-
89
- it("parses SKDM with optional signing public key", () => {
90
- const chainKey = Buffer.alloc(32, 1);
91
- const signingPublicKey = Buffer.alloc(33, 2);
92
- const encoded = Buffer.concat([
93
- fieldVarint(1, 123),
94
- fieldVarint(2, 456),
95
- fieldBytes(3, chainKey),
96
- fieldBytes(4, signingPublicKey),
97
- fieldBytes(12, Buffer.from("unknown")),
98
- ]);
99
-
100
- expect(parseFBProtobufSKDM(encoded)).toEqual({
101
- chainId: 123,
102
- iteration: 456,
103
- chainKey,
104
- signingPublicKey,
105
- });
106
- });
107
-
108
- it("returns null for malformed SKDM length prefixes", () => {
109
- expect(parseFBProtobufSKDM(Buffer.from([0x1a, 0x80]))).toBeNull();
110
- });
111
- });
112
-
113
- describe("wrapAsSignalSKMSG", () => {
114
- it("wraps FB sender-key message fields and appends deterministic signature", () => {
115
- const distributionId = "6ba7b810-9dad-41d1-80b4-00c04fd430c8";
116
- const ciphertext = Buffer.from("group-ciphertext");
117
- const wrapped = wrapAsSignalSKMSG({
118
- distributionId,
119
- id: 5,
120
- iteration: 9,
121
- ciphertext,
122
- senderJid: "100.1@msgr",
123
- });
124
- const signedPayloadLength = 1 + 18 + 2 + 2 + 2 + ciphertext.length;
125
-
126
- expect(wrapped).toHaveLength(signedPayloadLength + 64);
127
- expect(wrapped[0]).toBe(0x33);
128
- expect(wrapped.subarray(1, 19)).toEqual(Buffer.concat([Buffer.from([0x0a, 0x10]), uuidParse(distributionId)]));
129
- expect(wrapped.includes(ciphertext)).toBe(true);
130
- expect(wrapped.subarray(0, signedPayloadLength).includes(ciphertext)).toBe(true);
131
- expect(wrapped.subarray(signedPayloadLength)).toHaveLength(64);
132
- });
133
- });
134
- });