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.
- package/DOCS.md +310 -0
- package/LICENSE +660 -0
- package/README.md +153 -0
- package/bun.lock +1014 -0
- package/examples/basic-usage.ts +52 -0
- package/jest.config.ts +18 -0
- package/package.json +56 -0
- package/src/config/env.ts +15 -0
- package/src/controllers/client.controller.ts +805 -0
- package/src/controllers/dgw-handler.ts +171 -0
- package/src/controllers/e2ee-handler.ts +832 -0
- package/src/controllers/event-mapper.ts +327 -0
- package/src/core/client.ts +151 -0
- package/src/e2ee/application/e2ee-client.ts +376 -0
- package/src/e2ee/application/fanout-planner.ts +79 -0
- package/src/e2ee/application/outbound-message-cache.ts +58 -0
- package/src/e2ee/application/prekey-maintenance.ts +55 -0
- package/src/e2ee/application/retry-manager.ts +156 -0
- package/src/e2ee/facebook/facebook-protocol-utils.ts +230 -0
- package/src/e2ee/facebook/icdc-payload.ts +31 -0
- package/src/e2ee/index.ts +76 -0
- package/src/e2ee/internal.ts +27 -0
- package/src/e2ee/media/media-crypto.ts +147 -0
- package/src/e2ee/media/media-upload.ts +90 -0
- package/src/e2ee/message/builders/client-payload.ts +54 -0
- package/src/e2ee/message/builders/consumer-application.ts +362 -0
- package/src/e2ee/message/builders/message-application.ts +64 -0
- package/src/e2ee/message/builders/message-transport.ts +84 -0
- package/src/e2ee/message/codecs/protobuf-codecs.ts +101 -0
- package/src/e2ee/message/constants.ts +4 -0
- package/src/e2ee/message/message-builder.ts +15 -0
- package/src/e2ee/message/proto/ArmadilloApplication.proto +281 -0
- package/src/e2ee/message/proto/ArmadilloICDC.proto +14 -0
- package/src/e2ee/message/proto/ConsumerApplication.proto +232 -0
- package/src/e2ee/message/proto/MessageApplication.proto +82 -0
- package/src/e2ee/message/proto/MessageTransport.proto +77 -0
- package/src/e2ee/message/proto/WACommon.proto +66 -0
- package/src/e2ee/message/proto/WAMediaTransport.proto +176 -0
- package/src/e2ee/message/proto/proto-writer.ts +76 -0
- package/src/e2ee/signal/prekey-manager.ts +140 -0
- package/src/e2ee/signal/signal-manager.ts +365 -0
- package/src/e2ee/store/device-json.ts +47 -0
- package/src/e2ee/store/device-repository.ts +12 -0
- package/src/e2ee/store/device-store.ts +538 -0
- package/src/e2ee/transport/binary/decoder.ts +180 -0
- package/src/e2ee/transport/binary/encoder.ts +143 -0
- package/src/e2ee/transport/binary/stanzas.ts +97 -0
- package/src/e2ee/transport/binary/tokens.ts +64 -0
- package/src/e2ee/transport/binary/wa-binary.ts +8 -0
- package/src/e2ee/transport/dgw/dgw-socket.ts +345 -0
- package/src/e2ee/transport/noise/noise-handshake.ts +417 -0
- package/src/e2ee/transport/noise/noise-socket.ts +230 -0
- package/src/index.ts +34 -0
- package/src/models/client.ts +26 -0
- package/src/models/config.ts +14 -0
- package/src/models/domain.ts +299 -0
- package/src/models/e2ee.ts +295 -0
- package/src/models/media.ts +41 -0
- package/src/models/messaging.ts +88 -0
- package/src/models/thread.ts +69 -0
- package/src/repositories/session.repository.ts +20 -0
- package/src/services/auth.service.ts +55 -0
- package/src/services/e2ee.service.ts +174 -0
- package/src/services/facebook-gateway.service.ts +245 -0
- package/src/services/icdc.service.ts +177 -0
- package/src/services/media.service.ts +304 -0
- package/src/services/messaging.service.ts +28 -0
- package/src/services/thread.service.ts +199 -0
- package/src/types/advanced-types.ts +61 -0
- package/src/types/fca-unofficial.d.ts +212 -0
- package/src/types/protocol-types.ts +43 -0
- package/src/utils/fca-utils.ts +30 -0
- package/src/utils/logger.ts +24 -0
- package/src/utils/mime.ts +51 -0
- package/tests/.env.example +87 -0
- package/tests/data/1x1.png +0 -0
- package/tests/data/example.txt +1 -0
- package/tests/data/file_example.mp3 +0 -0
- package/tests/data/file_example.mp4 +0 -0
- package/tests/integration.test.ts +498 -0
- package/tests/script/echo-e2ee.ts +174 -0
- package/tests/script/send-e2ee-media.ts +227 -0
- package/tests/script/send-e2ee-reaction.ts +108 -0
- package/tests/script/send-e2ee-unsend.ts +115 -0
- package/tests/script/send-typing.ts +107 -0
- package/tests/script/test-group-send.ts +105 -0
- package/tests/setup.ts +3 -0
- package/tests/types.test.ts +57 -0
- package/tests/unit/controllers/dgw-handler.test.ts +60 -0
- package/tests/unit/controllers/e2ee-handler.test.ts +293 -0
- package/tests/unit/controllers/event-mapper.test.ts +252 -0
- package/tests/unit/e2ee/application-helpers.test.ts +418 -0
- package/tests/unit/e2ee/device-store.test.ts +126 -0
- package/tests/unit/e2ee/facebook-protocol-utils.test.ts +134 -0
- package/tests/unit/e2ee/media-crypto.test.ts +55 -0
- package/tests/unit/e2ee/media-upload.test.ts +60 -0
- package/tests/unit/e2ee/message-builder.test.ts +42 -0
- package/tests/unit/e2ee/message-builders.test.ts +230 -0
- package/tests/unit/e2ee/noise-handshake.test.ts +260 -0
- package/tests/unit/e2ee/prekey-manager.test.ts +55 -0
- package/tests/unit/e2ee/wa-binary.test.ts +127 -0
- package/tests/unit/services/e2ee.service.test.ts +101 -0
- package/tests/unit/services/facebook-gateway.service.test.ts +138 -0
- package/tests/unit/services/media.service.test.ts +169 -0
- package/tests/unit/utils/fca-utils.test.ts +48 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import {
|
|
2
|
+
unmarshal,
|
|
3
|
+
marshal,
|
|
4
|
+
encodeIQ,
|
|
5
|
+
encodeNode,
|
|
6
|
+
encodePreKeyUpload,
|
|
7
|
+
type Node
|
|
8
|
+
} from "../e2ee/transport/binary/wa-binary.ts";
|
|
9
|
+
import {
|
|
10
|
+
decodeMessageTransport,
|
|
11
|
+
decodeMessageApplication,
|
|
12
|
+
decodeConsumerApplication,
|
|
13
|
+
decodeArmadillo,
|
|
14
|
+
ProtoWriter
|
|
15
|
+
} from "../e2ee/message/message-builder.ts";
|
|
16
|
+
import {
|
|
17
|
+
generatePreKeys,
|
|
18
|
+
generateSignedPreKey
|
|
19
|
+
} from "../e2ee/signal/prekey-manager.ts";
|
|
20
|
+
import { str, num, now } from "../utils/fca-utils.ts";
|
|
21
|
+
import type { DeviceStore } from "../e2ee/store/device-store.ts";
|
|
22
|
+
import type { FacebookE2EESocket } from "../e2ee/transport/noise/noise-socket.ts";
|
|
23
|
+
import type { E2EEClient } from "../e2ee/application/e2ee-client.ts";
|
|
24
|
+
import type { EventMapper } from "./event-mapper.ts";
|
|
25
|
+
import type { RawPreKeyBundle } from "../models/e2ee.ts";
|
|
26
|
+
import type { MediaUploadConfig } from "../models/media.ts";
|
|
27
|
+
import { logger } from "../utils/logger.ts";
|
|
28
|
+
|
|
29
|
+
type RetryReceiptHandler = (node: Node) => void | Promise<void>;
|
|
30
|
+
|
|
31
|
+
export class E2EEHandler {
|
|
32
|
+
private readonly pendingIQs = new Map<string, { resolve: (val: any) => void; reject: (err: any) => void }>();
|
|
33
|
+
private readonly retryReceipts = new Map<string, number>();
|
|
34
|
+
private readonly maxRetryReceiptsPerMessage = 2;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly eventMapper: EventMapper,
|
|
38
|
+
private readonly getSocket: () => FacebookE2EESocket | null,
|
|
39
|
+
private readonly getStore: () => DeviceStore | null,
|
|
40
|
+
private readonly onRetryReceipt?: RetryReceiptHandler,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
public async handleEncryptedMessage(node: Node, selfUserId: string, e2eeClient: E2EEClient) {
|
|
44
|
+
const fromJid = node.attrs.from;
|
|
45
|
+
const participantJid = node.attrs.participant || node.attrs.from;
|
|
46
|
+
const senderJid = participantJid;
|
|
47
|
+
let chatJid = node.attrs.from;
|
|
48
|
+
const selfDevice = this.getStore()?.jidDevice ?? 0;
|
|
49
|
+
const selfJid = `${selfUserId}.${selfDevice}@msgr`;
|
|
50
|
+
|
|
51
|
+
// Check participant-specific SKDM in <participants>.
|
|
52
|
+
// Messenger may encode our device JID as user.device@msgr or user:device@msgr,
|
|
53
|
+
// and stale stores may not know jidDevice yet. Try all entries for our user.
|
|
54
|
+
const participantsNode = Array.isArray(node.content) ? node.content.find((c: any) => c.tag === "participants") : null;
|
|
55
|
+
let emittedParticipantApp = false;
|
|
56
|
+
if (participantsNode && Array.isArray(participantsNode.content)) {
|
|
57
|
+
const selfToNodes = participantsNode.content.filter((n: any) =>
|
|
58
|
+
n.tag === "to" && this.sameMessengerUser(n.attrs.jid, selfJid)
|
|
59
|
+
);
|
|
60
|
+
let processedSKDM = false;
|
|
61
|
+
|
|
62
|
+
for (const toNode of selfToNodes) {
|
|
63
|
+
if (!Array.isArray(toNode.content)) continue;
|
|
64
|
+
const targetJid = typeof toNode.attrs.jid === "string" ? toNode.attrs.jid : selfJid;
|
|
65
|
+
const myEnc = toNode.content.find((n: any) => n.tag === "enc");
|
|
66
|
+
if (!myEnc || !Buffer.isBuffer(myEnc.content)) continue;
|
|
67
|
+
|
|
68
|
+
logger.debug("E2EEHandler", `Trying participant SKDM DM from ${senderJid} to ${targetJid}`);
|
|
69
|
+
try {
|
|
70
|
+
let dmDecrypted: Buffer | null = null;
|
|
71
|
+
if (myEnc.attrs.type === "msg") {
|
|
72
|
+
dmDecrypted = await e2eeClient.decryptDMMessage(senderJid, myEnc.content);
|
|
73
|
+
} else if (myEnc.attrs.type === "pkmsg") {
|
|
74
|
+
dmDecrypted = await e2eeClient.decryptDMPreKeyMessage(senderJid, targetJid, myEnc.content);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!dmDecrypted) continue;
|
|
78
|
+
const transport = decodeMessageTransport(dmDecrypted);
|
|
79
|
+
const participantChatJid = this.chatJidFromTransport(transport, chatJid);
|
|
80
|
+
if (this.emitTransportApplication(transport, senderJid, participantChatJid, node.attrs.id)) {
|
|
81
|
+
emittedParticipantApp = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const skdm = transport?.protocol?.ancillary?.skdm;
|
|
85
|
+
const gid = skdm?.groupID || skdm?.groupId || chatJid;
|
|
86
|
+
const skBytes = skdm?.axolotlSenderKeyDistributionMessage || skdm?.skdmBytes;
|
|
87
|
+
if (skBytes) {
|
|
88
|
+
logger.info("E2EEHandler", `Processing SKDM from participants node for group ${gid} from ${senderJid}`);
|
|
89
|
+
await e2eeClient.processSenderKeyDistribution(senderJid, skBytes, gid);
|
|
90
|
+
processedSKDM = true;
|
|
91
|
+
if (!emittedParticipantApp) break;
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.debug("E2EEHandler", `Participant SKDM decrypt failed for ${targetJid}: ${err}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (selfToNodes.length > 0 && !processedSKDM && !emittedParticipantApp) {
|
|
99
|
+
logger.warn("E2EEHandler", `Found ${selfToNodes.length} participant node(s) for self but no SKDM could be processed from ${senderJid}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//Process main 'enc' node
|
|
104
|
+
const enc = Array.isArray(node.content)
|
|
105
|
+
? node.content.find((c: any) => c.tag === "enc")
|
|
106
|
+
: (node.content?.tag === "enc" ? node.content : null);
|
|
107
|
+
|
|
108
|
+
if (!enc) {
|
|
109
|
+
const unavailable = Array.isArray(node.content)
|
|
110
|
+
? node.content.find((c: any) => c.tag === "unavailable")
|
|
111
|
+
: (node.content?.tag === "unavailable" ? node.content : null);
|
|
112
|
+
if (emittedParticipantApp) {
|
|
113
|
+
this.sendAck(node);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (unavailable) {
|
|
118
|
+
const err = new Error(`unavailable encrypted message${unavailable.attrs?.type ? `: ${unavailable.attrs.type}` : ""}`);
|
|
119
|
+
await this.maybeSendRetryReceipt(node, senderJid, chatJid, err);
|
|
120
|
+
this.eventMapper.emitMappedEvent({
|
|
121
|
+
type: "e2ee_message",
|
|
122
|
+
data: {
|
|
123
|
+
type: "decryption_failed",
|
|
124
|
+
error: err.message,
|
|
125
|
+
chatJid,
|
|
126
|
+
threadId: chatJid,
|
|
127
|
+
senderJid,
|
|
128
|
+
senderId: this.parseMessengerJid(senderJid).user,
|
|
129
|
+
messageId: node.attrs.id,
|
|
130
|
+
timestampMs: now()
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
this.sendAck(node);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const type = enc.attrs.type;
|
|
139
|
+
const ciphertext = enc.content;
|
|
140
|
+
|
|
141
|
+
if (!Buffer.isBuffer(ciphertext)) {
|
|
142
|
+
this.sendAck(node);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
let decrypted: Buffer;
|
|
148
|
+
if (type === "msg") {
|
|
149
|
+
decrypted = await e2eeClient.decryptDMMessage(senderJid, ciphertext);
|
|
150
|
+
} else if (type === "pkmsg") {
|
|
151
|
+
decrypted = await e2eeClient.decryptDMPreKeyMessage(senderJid, selfJid, ciphertext);
|
|
152
|
+
} else if (type === "skmsg") {
|
|
153
|
+
decrypted = await e2eeClient.decryptGroupMessage(senderJid, ciphertext, fromJid);
|
|
154
|
+
} else {
|
|
155
|
+
this.sendAck(node);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const transport = decodeMessageTransport(decrypted);
|
|
160
|
+
logger.debug("E2EEHandler", "Decrypted transport:", JSON.stringify(transport, null, 2));
|
|
161
|
+
chatJid = this.chatJidFromTransport(transport, chatJid);
|
|
162
|
+
this.emitTransportApplication(transport, senderJid, chatJid, node.attrs.id);
|
|
163
|
+
|
|
164
|
+
if (transport?.protocol?.ancillary?.skdm) {
|
|
165
|
+
const skdm = transport.protocol.ancillary.skdm;
|
|
166
|
+
const gid = skdm.groupID || skdm.groupId || fromJid;
|
|
167
|
+
const skBytes = skdm.axolotlSenderKeyDistributionMessage || skdm.skdmBytes;
|
|
168
|
+
if (skBytes) {
|
|
169
|
+
await e2eeClient.processSenderKeyDistribution(participantJid, skBytes, gid);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.sendAck(node);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logger.error("E2EEHandler", "Decryption failed:", err);
|
|
175
|
+
await this.maybeSendRetryReceipt(node, senderJid, chatJid, err);
|
|
176
|
+
this.sendAck(node);
|
|
177
|
+
this.eventMapper.emitMappedEvent({
|
|
178
|
+
type: "e2ee_message",
|
|
179
|
+
data: {
|
|
180
|
+
type: "decryption_failed",
|
|
181
|
+
error: (err as Error).message,
|
|
182
|
+
chatJid,
|
|
183
|
+
threadId: chatJid,
|
|
184
|
+
senderJid,
|
|
185
|
+
senderId: this.parseMessengerJid(senderJid).user,
|
|
186
|
+
messageId: node.attrs.id,
|
|
187
|
+
timestampMs: now()
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private parseMessengerJid(jid: string | undefined): { user: string; device: number; server: string } {
|
|
194
|
+
const value = jid ?? "";
|
|
195
|
+
const [userPart = value, server = ""] = value.split("@");
|
|
196
|
+
const colonIdx = userPart.indexOf(":");
|
|
197
|
+
const dotIdx = userPart.indexOf(".");
|
|
198
|
+
const userEnd = dotIdx !== -1 ? dotIdx : (colonIdx !== -1 ? colonIdx : userPart.length);
|
|
199
|
+
const user = userPart.slice(0, userEnd) || userPart;
|
|
200
|
+
const rawDevice = colonIdx !== -1
|
|
201
|
+
? userPart.slice(colonIdx + 1)
|
|
202
|
+
: (dotIdx !== -1 ? userPart.slice(dotIdx + 1) : "0");
|
|
203
|
+
return { user, device: Number(rawDevice) || 0, server };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private sameMessengerDevice(a: string | undefined, b: string): boolean {
|
|
207
|
+
const pa = this.parseMessengerJid(a);
|
|
208
|
+
const pb = this.parseMessengerJid(b);
|
|
209
|
+
return pa.server === "msgr" && pb.server === "msgr" && pa.user === pb.user && pa.device === pb.device;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private sameMessengerUser(a: string | undefined, b: string): boolean {
|
|
213
|
+
const pa = this.parseMessengerJid(a);
|
|
214
|
+
const pb = this.parseMessengerJid(b);
|
|
215
|
+
return pa.server === "msgr" && pb.server === "msgr" && pa.user === pb.user;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async maybeSendRetryReceipt(node: Node, senderJid: string, chatJid: string, err: unknown): Promise<void> {
|
|
219
|
+
const messageId = node.attrs.id;
|
|
220
|
+
if (!messageId) return;
|
|
221
|
+
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err ?? "");
|
|
223
|
+
const retryable = /missing sender key state|No session|decrypt|invalid|unavailable/i.test(message);
|
|
224
|
+
if (!retryable) return;
|
|
225
|
+
|
|
226
|
+
const key = `${chatJid}:${senderJid}:${messageId}`;
|
|
227
|
+
const count = (this.retryReceipts.get(key) ?? 0) + 1;
|
|
228
|
+
if (count > this.maxRetryReceiptsPerMessage) {
|
|
229
|
+
logger.warn("E2EEHandler", `Skip retry receipt for ${messageId}; retry limit reached`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.retryReceipts.set(key, count);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
await this.sendRetryReceipt(node, senderJid, count);
|
|
236
|
+
logger.info("E2EEHandler", `Sent retry receipt #${count} for ${messageId} to recover missing E2EE keys`);
|
|
237
|
+
} catch (retryErr) {
|
|
238
|
+
logger.warn("E2EEHandler", `Failed to send retry receipt for ${messageId}: ${retryErr}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async sendRetryReceipt(node: Node, senderJid: string, retryCount: number): Promise<void> {
|
|
243
|
+
const socket = this.getSocket();
|
|
244
|
+
const store = this.getStore();
|
|
245
|
+
if (!socket || !store?.registrationId) return;
|
|
246
|
+
|
|
247
|
+
const receiptAttrs: Record<string, string> = {
|
|
248
|
+
id: node.attrs.id,
|
|
249
|
+
to: node.attrs.from,
|
|
250
|
+
type: "retry",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (node.attrs.participant || node.attrs.from?.endsWith("@g.us")) {
|
|
254
|
+
receiptAttrs.participant = node.attrs.participant || senderJid;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const retryAttrs: Record<string, string> = {
|
|
258
|
+
count: String(retryCount),
|
|
259
|
+
id: node.attrs.id,
|
|
260
|
+
t: String(node.attrs.t || Math.floor(now() / 1000)),
|
|
261
|
+
v: "1",
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const regBuf = Buffer.alloc(4);
|
|
265
|
+
regBuf.writeUInt32BE(store.registrationId);
|
|
266
|
+
|
|
267
|
+
const children: Buffer[] = [
|
|
268
|
+
encodeNode("retry", retryAttrs),
|
|
269
|
+
encodeNode("registration", {}, regBuf),
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
// Include a fresh one-time prekey and current signed prekey so the sender
|
|
273
|
+
// can rebuild a session before resending SKDM/message. This preserves the
|
|
274
|
+
// same registered device identity; it does not perform ICDC registration.
|
|
275
|
+
const keysNode = await this.buildRetryKeysNode(store).catch((err) => {
|
|
276
|
+
logger.debug("E2EEHandler", `Could not build retry keys node: ${err}`);
|
|
277
|
+
return null;
|
|
278
|
+
});
|
|
279
|
+
if (keysNode) children.push(keysNode);
|
|
280
|
+
|
|
281
|
+
const receipt = encodeNode("receipt", receiptAttrs, children);
|
|
282
|
+
await socket.sendFrame(marshal(receipt));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async buildRetryKeysNode(store: DeviceStore): Promise<Buffer> {
|
|
286
|
+
const [preKey] = await generatePreKeys(store, 1);
|
|
287
|
+
if (!preKey) throw new Error("failed to generate retry prekey");
|
|
288
|
+
|
|
289
|
+
let signedPreKey = await store.getSignedPreKey(store.signedPreKeyId).catch(() => null as any);
|
|
290
|
+
if (!signedPreKey) signedPreKey = await generateSignedPreKey(store);
|
|
291
|
+
|
|
292
|
+
return encodeNode("keys", {}, [
|
|
293
|
+
encodeNode("type", {}, Buffer.from([0x05])),
|
|
294
|
+
encodeNode("identity", {}, store.getIdentityPublicKey()),
|
|
295
|
+
this.encodeSignalKeyNode("key", preKey.id, Buffer.from(preKey.record.publicKey().getPublicKeyBytes())),
|
|
296
|
+
this.encodeSignalKeyNode(
|
|
297
|
+
"skey",
|
|
298
|
+
signedPreKey.id(),
|
|
299
|
+
Buffer.from(signedPreKey.publicKey().getPublicKeyBytes()),
|
|
300
|
+
Buffer.from(signedPreKey.signature()),
|
|
301
|
+
),
|
|
302
|
+
encodeNode("device-identity", {}, this.encodeDummyDeviceIdentity()),
|
|
303
|
+
]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private encodeDummyDeviceIdentity(): Buffer {
|
|
307
|
+
return new ProtoWriter()
|
|
308
|
+
.bytes(1, Buffer.alloc(0))
|
|
309
|
+
.bytes(2, Buffer.alloc(32))
|
|
310
|
+
.bytes(3, Buffer.alloc(64))
|
|
311
|
+
.bytes(4, Buffer.alloc(64))
|
|
312
|
+
.build();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private encodeSignalKeyNode(tag: "key" | "skey", id: number, publicKey: Buffer, signature?: Buffer): Buffer {
|
|
316
|
+
const idBuf = Buffer.alloc(4);
|
|
317
|
+
idBuf.writeUInt32BE(id);
|
|
318
|
+
const children = [
|
|
319
|
+
encodeNode("id", {}, idBuf.subarray(1)),
|
|
320
|
+
encodeNode("value", {}, publicKey),
|
|
321
|
+
];
|
|
322
|
+
if (signature) children.push(encodeNode("signature", {}, signature));
|
|
323
|
+
return encodeNode(tag, {}, children);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public async handleReceipt(node: Node): Promise<void> {
|
|
327
|
+
const d = {
|
|
328
|
+
type: node.attrs.type || "delivery",
|
|
329
|
+
chat: node.attrs.from || "",
|
|
330
|
+
sender: node.attrs.participant || node.attrs.from || "",
|
|
331
|
+
messageIds: node.attrs.id ? [node.attrs.id] : [],
|
|
332
|
+
};
|
|
333
|
+
this.eventMapper.emitMappedEvent({ type: "e2ee_receipt", data: d });
|
|
334
|
+
|
|
335
|
+
if (node.attrs.type === "retry") {
|
|
336
|
+
await this.onRetryReceipt?.(node);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
public async handleNotification(node: Node): Promise<void> {
|
|
341
|
+
const notifType = node.attrs.type;
|
|
342
|
+
if (notifType === "encrypt") {
|
|
343
|
+
await this.handleEncryptNotification(node);
|
|
344
|
+
} else {
|
|
345
|
+
logger.debug("E2EEHandler", `Unhandled notification type ${notifType || "<none>"}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async handleEncryptNotification(node: Node): Promise<void> {
|
|
350
|
+
const children = Array.isArray(node.content) ? node.content : (node.content ? [node.content] : []);
|
|
351
|
+
const countNode = children.find((child: any) => child.tag === "count");
|
|
352
|
+
const value = Number(countNode?.attrs?.value);
|
|
353
|
+
if (Number.isFinite(value)) {
|
|
354
|
+
logger.info("E2EEHandler", `Server encrypt notification reports ${value} prekeys remaining`);
|
|
355
|
+
if (value < 5) await this.uploadPreKeys(50);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const identityNode = children.find((child: any) => child.tag === "identity");
|
|
360
|
+
if (identityNode) {
|
|
361
|
+
logger.warn("E2EEHandler", `Received identity-change notification from ${node.attrs.from}; sessions may need refresh`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
logger.debug("E2EEHandler", `Unhandled encrypt notification from ${node.attrs.from || "server"}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private chatJidFromTransport(transport: any, fallback: string): string {
|
|
369
|
+
const integral = transport?.protocol?.integral;
|
|
370
|
+
const dsm = integral?.DSM || integral?.dsm;
|
|
371
|
+
return dsm?.destinationJID || dsm?.destinationJid || fallback;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private emitTransportApplication(transport: any, senderJid: string, chatJid: string, messageId: string): boolean {
|
|
375
|
+
const appPayload = transport?.payload?.applicationPayload?.payload;
|
|
376
|
+
if (!appPayload) return false;
|
|
377
|
+
|
|
378
|
+
const messageApp = decodeMessageApplication(appPayload);
|
|
379
|
+
logger.debug("E2EEHandler", "Decrypted messageApp:", JSON.stringify(messageApp, null, 2));
|
|
380
|
+
const subProtocol = messageApp.payload?.subProtocol;
|
|
381
|
+
let appMessage: any = null;
|
|
382
|
+
let isArmadillo = false;
|
|
383
|
+
|
|
384
|
+
if (subProtocol?.consumerMessage?.payload) {
|
|
385
|
+
appMessage = decodeConsumerApplication(subProtocol.consumerMessage.payload);
|
|
386
|
+
} else if (subProtocol?.armadillo?.payload) {
|
|
387
|
+
appMessage = decodeArmadillo(subProtocol.armadillo.payload);
|
|
388
|
+
isArmadillo = true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!appMessage) return false;
|
|
392
|
+
|
|
393
|
+
const normalized = this.normalizeE2EEMessage(appMessage, senderJid, chatJid, messageId, messageApp);
|
|
394
|
+
if (!normalized) return false;
|
|
395
|
+
normalized.isArmadillo = isArmadillo;
|
|
396
|
+
this.eventMapper.emitMappedEvent({ type: "e2ee_message", data: normalized });
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
public handleIQ(node: Node) {
|
|
401
|
+
const id = node.attrs.id;
|
|
402
|
+
const xmlns = node.attrs.xmlns;
|
|
403
|
+
const type = node.attrs.type;
|
|
404
|
+
|
|
405
|
+
if (xmlns === "urn:xmpp:ping" && type === "get") {
|
|
406
|
+
const pong = encodeIQ({ id, to: node.attrs.from, type: "result" });
|
|
407
|
+
this.getSocket()?.sendFrame(marshal(pong));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
logger.debug("E2EEHandler", `Handling IQ: id=${id}, type=${type}, xmlns=${node.attrs.xmlns}`);
|
|
411
|
+
|
|
412
|
+
if (type === "result") {
|
|
413
|
+
const content = node.content;
|
|
414
|
+
let countNode = null;
|
|
415
|
+
if (Array.isArray(content)) {
|
|
416
|
+
countNode = content.find(n => n && typeof n === "object" && n.tag === "count");
|
|
417
|
+
} else if (content && typeof content === "object" && (content as any).tag === "count") {
|
|
418
|
+
countNode = content;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (countNode) {
|
|
422
|
+
const count = parseInt(countNode.attrs.value ?? "0");
|
|
423
|
+
this.pendingIQs.get(id)?.resolve(count);
|
|
424
|
+
this.pendingIQs.delete(id);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
this.pendingIQs.get(id)?.resolve(node);
|
|
429
|
+
this.pendingIQs.delete(id);
|
|
430
|
+
} else if (type === "error") {
|
|
431
|
+
this.pendingIQs.get(id)?.reject(new Error(`IQ Error: ${JSON.stringify(node.content)}`));
|
|
432
|
+
this.pendingIQs.delete(id);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
public handleIB(node: Node) {
|
|
437
|
+
const children = Array.isArray(node.content) ? node.content : (node.content ? [node.content] : []);
|
|
438
|
+
for (const child of children) {
|
|
439
|
+
if (child.tag === "dirty") {
|
|
440
|
+
const type = child.attrs.type;
|
|
441
|
+
const timestamp = child.attrs.timestamp;
|
|
442
|
+
if (type === "account_sync") {
|
|
443
|
+
this.sendCleanIQ(type, timestamp).catch(() => {});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
public async getMediaUploadConfig(): Promise<MediaUploadConfig> {
|
|
451
|
+
const id = `mc-${now()}`;
|
|
452
|
+
const iq = encodeIQ({ id, to: "s.whatsapp.net", type: "set", xmlns: "w:m" }, [
|
|
453
|
+
encodeNode("media_conn", {}, undefined),
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
logger.debug("E2EEHandler", `Sending media_conn IQ (id=${id})`);
|
|
457
|
+
|
|
458
|
+
const res = await new Promise<Node>((resolve, reject) => {
|
|
459
|
+
this.pendingIQs.set(id, { resolve, reject });
|
|
460
|
+
this.getSocket()?.sendFrame(iq).catch(reject);
|
|
461
|
+
setTimeout(() => {
|
|
462
|
+
if (this.pendingIQs.has(id)) {
|
|
463
|
+
this.pendingIQs.delete(id);
|
|
464
|
+
reject(new Error("media_conn timeout (10s)"));
|
|
465
|
+
}
|
|
466
|
+
}, 10000);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const findTag = (node: any, tag: string): any => {
|
|
470
|
+
if (node?.tag === tag) return node;
|
|
471
|
+
if (Array.isArray(node?.content)) {
|
|
472
|
+
for (const child of node.content) {
|
|
473
|
+
const found = findTag(child, tag);
|
|
474
|
+
if (found) return found;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const mediaConn = findTag(res, "media_conn");
|
|
481
|
+
if (!mediaConn) {
|
|
482
|
+
logger.error("E2EEHandler", `media_conn IQ response missing <media_conn> node. Full response: ${JSON.stringify(res)}`);
|
|
483
|
+
throw new Error("Missing media_conn in response");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const children = Array.isArray(mediaConn.content) ? mediaConn.content : [];
|
|
487
|
+
const hosts = children
|
|
488
|
+
.filter((child: any) => child.tag === "host" && child.attrs?.hostname)
|
|
489
|
+
.map((child: any) => String(child.attrs.hostname));
|
|
490
|
+
const host = hosts.at(-1) || process.env.FB_E2EE_MEDIA_UPLOAD_HOST || "rupload.facebook.com";
|
|
491
|
+
const auth = str(mediaConn.attrs?.auth);
|
|
492
|
+
const ttl = num(mediaConn.attrs?.ttl);
|
|
493
|
+
const authTtl = num(mediaConn.attrs?.auth_ttl);
|
|
494
|
+
|
|
495
|
+
logger.debug("E2EEHandler", `media_conn received: host=${host}, auth=${auth ? `${auth.slice(0, 12)}...` : "(empty)"}, ttl=${ttl}, auth_ttl=${authTtl}`);
|
|
496
|
+
|
|
497
|
+
if (!auth) {
|
|
498
|
+
logger.error("E2EEHandler", `media_conn response has no auth attribute. Attrs: ${JSON.stringify(mediaConn.attrs)}`);
|
|
499
|
+
throw new Error("Missing media_conn auth token");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
host,
|
|
504
|
+
auth,
|
|
505
|
+
ttl,
|
|
506
|
+
authTtl,
|
|
507
|
+
fetchedAtMs: now(),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
public async getServerPreKeyCount(): Promise<number> {
|
|
512
|
+
const id = `pkc-${now()}`;
|
|
513
|
+
const iq = encodeIQ({ id, to: "s.whatsapp.net", type: "get", xmlns: "encrypt" }, [
|
|
514
|
+
encodeNode("count", {}, undefined)
|
|
515
|
+
]);
|
|
516
|
+
|
|
517
|
+
return new Promise((resolve, reject) => {
|
|
518
|
+
this.pendingIQs.set(id, { resolve, reject });
|
|
519
|
+
this.getSocket()?.sendFrame(iq).catch(reject);
|
|
520
|
+
setTimeout(() => {
|
|
521
|
+
if (this.pendingIQs.has(id)) {
|
|
522
|
+
this.pendingIQs.delete(id);
|
|
523
|
+
resolve(0);
|
|
524
|
+
}
|
|
525
|
+
}, 5000);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
public async getGroupParticipants(groupJid: string): Promise<string[]> {
|
|
530
|
+
const id = `gp-${now()}`;
|
|
531
|
+
const iq = encodeIQ({ id, to: groupJid, type: "get", xmlns: "w:g2" }, [
|
|
532
|
+
encodeNode("query", { request: "interactive" }, undefined)
|
|
533
|
+
]);
|
|
534
|
+
|
|
535
|
+
logger.debug("E2EEHandler", `Sending getGroupParticipants IQ for ${groupJid}, id: ${id}`);
|
|
536
|
+
const res = await new Promise<Node>((resolve, reject) => {
|
|
537
|
+
this.pendingIQs.set(id, { resolve, reject });
|
|
538
|
+
this.getSocket()?.sendFrame(iq).catch(err => {
|
|
539
|
+
logger.error("E2EEHandler", `Failed to send getGroupParticipants IQ:`, err);
|
|
540
|
+
reject(err);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
setTimeout(() => {
|
|
544
|
+
if (this.pendingIQs.has(id)) {
|
|
545
|
+
logger.error("E2EEHandler", `getGroupParticipants IQ timeout for ${id}`);
|
|
546
|
+
this.pendingIQs.delete(id);
|
|
547
|
+
reject(new Error(`getGroupParticipants timeout for ${groupJid}`));
|
|
548
|
+
}
|
|
549
|
+
}, 10000);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
logger.debug("E2EEHandler", `Received getGroupParticipants response for ${id}`);
|
|
553
|
+
|
|
554
|
+
const groupNode = Array.isArray(res.content) ? res.content.find(n => n.tag === "group") : null;
|
|
555
|
+
if (!groupNode || !Array.isArray(groupNode.content)) {
|
|
556
|
+
logger.warn("E2EEHandler", `No group node found in getGroupParticipants response for ${groupJid}`);
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const participants = groupNode.content
|
|
561
|
+
.filter((n: any) => n.tag === "participant" && n.attrs.jid)
|
|
562
|
+
.map((n: any) => n.attrs.jid);
|
|
563
|
+
|
|
564
|
+
logger.info("E2EEHandler", `Found ${participants.length} participants for ${groupJid}`);
|
|
565
|
+
return participants;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
public async getDeviceList(userJids: string[]): Promise<string[]> {
|
|
569
|
+
if (userJids.length === 0) return [];
|
|
570
|
+
|
|
571
|
+
const id = `${now()}`;
|
|
572
|
+
const iq = encodeIQ({
|
|
573
|
+
id,
|
|
574
|
+
to: "s.whatsapp.net",
|
|
575
|
+
type: "get",
|
|
576
|
+
xmlns: "fbid:devices",
|
|
577
|
+
}, [
|
|
578
|
+
encodeNode("users", {}, userJids.map(jid => encodeNode("user", { jid })))
|
|
579
|
+
]);
|
|
580
|
+
|
|
581
|
+
logger.debug("E2EEHandler", `Sending getDeviceList IQ for ${userJids.length} users, id: ${id}`);
|
|
582
|
+
const res = await new Promise<Node>((resolve, reject) => {
|
|
583
|
+
this.pendingIQs.set(id, { resolve, reject });
|
|
584
|
+
this.getSocket()?.sendFrame(iq).catch(err => {
|
|
585
|
+
logger.error("E2EEHandler", `Failed to send getDeviceList IQ:`, err);
|
|
586
|
+
reject(err);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
setTimeout(() => {
|
|
590
|
+
if (this.pendingIQs.has(id)) {
|
|
591
|
+
logger.error("E2EEHandler", `getDeviceList IQ timeout for ${id}`);
|
|
592
|
+
this.pendingIQs.delete(id);
|
|
593
|
+
reject(new Error(`getDeviceList timeout for ${userJids.length} users`));
|
|
594
|
+
}
|
|
595
|
+
}, 10000);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
logger.debug("E2EEHandler", `Received getDeviceList response for ${id}`);
|
|
599
|
+
|
|
600
|
+
const usersNode = Array.isArray(res.content) ? res.content.find(n => n.tag === "users") : null;
|
|
601
|
+
if (!usersNode || !Array.isArray(usersNode.content)) return [];
|
|
602
|
+
|
|
603
|
+
const deviceJids: string[] = [];
|
|
604
|
+
for (const userNode of usersNode.content) {
|
|
605
|
+
if (userNode.tag !== "user" || !Array.isArray(userNode.content)) continue;
|
|
606
|
+
|
|
607
|
+
const devicesNode = userNode.content.find((n: any) => n.tag === "devices");
|
|
608
|
+
if (!devicesNode || !Array.isArray(devicesNode.content)) continue;
|
|
609
|
+
|
|
610
|
+
const baseJid = userNode.attrs.jid; // e.g. 12345.0@msgr, 12345:0@msgr or 12345@msgr
|
|
611
|
+
const parsed = this.parseMessengerJid(baseJid);
|
|
612
|
+
const userId = parsed.user;
|
|
613
|
+
const server = parsed.server;
|
|
614
|
+
|
|
615
|
+
for (const deviceNode of devicesNode.content) {
|
|
616
|
+
if (deviceNode.tag === "device" && deviceNode.attrs.id) {
|
|
617
|
+
deviceJids.push(`${userId}.${deviceNode.attrs.id}@${server}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
logger.info("E2EEHandler", `Discovered ${deviceJids.length} devices for ${userJids.length} users`);
|
|
623
|
+
return deviceJids;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
public async getPreKeyBundle(jid: string): Promise<RawPreKeyBundle> {
|
|
627
|
+
const id = `pkb-${now()}`;
|
|
628
|
+
const iq = encodeIQ({ id, to: "s.whatsapp.net", type: "get", xmlns: "encrypt" }, [
|
|
629
|
+
encodeNode("key", {}, [
|
|
630
|
+
encodeNode("user", { jid }, undefined)
|
|
631
|
+
])
|
|
632
|
+
]);
|
|
633
|
+
|
|
634
|
+
const res = await new Promise<Node>((resolve, reject) => {
|
|
635
|
+
this.pendingIQs.set(id, { resolve, reject });
|
|
636
|
+
this.getSocket()?.sendFrame(iq).catch(reject);
|
|
637
|
+
setTimeout(() => {
|
|
638
|
+
if (this.pendingIQs.has(id)) {
|
|
639
|
+
this.pendingIQs.delete(id);
|
|
640
|
+
reject(new Error(`getPreKeyBundle timeout for ${jid}`));
|
|
641
|
+
}
|
|
642
|
+
}, 10000);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Parse prekey bundle from response
|
|
646
|
+
logger.debug("E2EEHandler", `getPreKeyBundle response for ${jid}: ${JSON.stringify(res, (k, v) => Buffer.isBuffer(v) ? v.toString("hex") : v)}`);
|
|
647
|
+
|
|
648
|
+
const findTag = (node: any, tag: string): any => {
|
|
649
|
+
if (node?.tag === tag) return node;
|
|
650
|
+
if (Array.isArray(node?.content)) {
|
|
651
|
+
for (const child of node.content) {
|
|
652
|
+
const found = findTag(child, tag);
|
|
653
|
+
if (found) return found;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return null;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const userNode = findTag(res, "user");
|
|
660
|
+
const keyNode = findTag(res, "key");
|
|
661
|
+
|
|
662
|
+
if (!userNode) throw new Error(`Missing user node in prekey bundle for ${jid}`);
|
|
663
|
+
if (!keyNode) throw new Error(`Missing key node in prekey bundle for ${jid}`);
|
|
664
|
+
|
|
665
|
+
const registration = findTag(userNode, "registration")?.content;
|
|
666
|
+
const identity = findTag(userNode, "identity")?.content;
|
|
667
|
+
const skey = findTag(userNode, "skey");
|
|
668
|
+
const key = findTag(keyNode, "key") || keyNode; // Could be the key node itself or have a nested key
|
|
669
|
+
|
|
670
|
+
if (!registration || !identity || !skey) throw new Error(`Missing required prekey components for ${jid}`);
|
|
671
|
+
|
|
672
|
+
const requireBuffer = (value: unknown, field: string): Buffer => {
|
|
673
|
+
if (Buffer.isBuffer(value)) return value;
|
|
674
|
+
throw new Error(`Missing or invalid ${field} in prekey bundle for ${jid}`);
|
|
675
|
+
};
|
|
676
|
+
const keyWithPrefix = (value: unknown, field: string): Buffer => {
|
|
677
|
+
const keyBytes = requireBuffer(value, field);
|
|
678
|
+
return keyBytes.length === 32 ? Buffer.concat([Buffer.from([5]), keyBytes]) : keyBytes;
|
|
679
|
+
};
|
|
680
|
+
const readKeyId = (value: unknown): number => {
|
|
681
|
+
if (!Buffer.isBuffer(value) || value.length === 0) return 0;
|
|
682
|
+
return value.readUIntBE(0, Math.min(value.length, 3));
|
|
683
|
+
};
|
|
684
|
+
const parseDeviceId = (deviceJid: string): number => {
|
|
685
|
+
const parsed = this.parseMessengerJid(deviceJid);
|
|
686
|
+
return Number.isFinite(parsed.device) && parsed.device > 0 ? parsed.device : 1;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const signedPreKeyId = findTag(skey, "id")?.content;
|
|
690
|
+
const signedPreKeyValue = findTag(skey, "value")?.content;
|
|
691
|
+
const signedPreKeySignature = findTag(skey, "signature")?.content;
|
|
692
|
+
const preKeyId = findTag(key, "id")?.content;
|
|
693
|
+
const preKeyValue = findTag(key, "value")?.content;
|
|
694
|
+
const hasPreKey = Boolean(findTag(key, "value"));
|
|
695
|
+
|
|
696
|
+
const bundle: RawPreKeyBundle = {
|
|
697
|
+
registrationId: Buffer.isBuffer(registration) && registration.length === 4 ? registration.readUInt32BE(0) : 0,
|
|
698
|
+
deviceId: parseDeviceId(jid),
|
|
699
|
+
identityKey: keyWithPrefix(identity, "identity"),
|
|
700
|
+
signedPreKey: {
|
|
701
|
+
keyId: readKeyId(signedPreKeyId),
|
|
702
|
+
publicKey: keyWithPrefix(signedPreKeyValue, "signed prekey public key"),
|
|
703
|
+
signature: requireBuffer(signedPreKeySignature, "signed prekey signature"),
|
|
704
|
+
},
|
|
705
|
+
preKey: hasPreKey ? {
|
|
706
|
+
keyId: readKeyId(preKeyId),
|
|
707
|
+
publicKey: keyWithPrefix(preKeyValue, "prekey public key"),
|
|
708
|
+
} : undefined,
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
return bundle;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
public async uploadPreKeys(count: number): Promise<void> {
|
|
715
|
+
const ds = this.getStore();
|
|
716
|
+
if (!ds) throw new Error("DeviceStore not loaded");
|
|
717
|
+
|
|
718
|
+
const preKeys = await generatePreKeys(ds, count);
|
|
719
|
+
const spk = await generateSignedPreKey(ds);
|
|
720
|
+
const idPair = await ds.getIdentityKeyPair();
|
|
721
|
+
|
|
722
|
+
const payload = encodePreKeyUpload(
|
|
723
|
+
ds.registrationId,
|
|
724
|
+
Buffer.from(idPair.publicKey.getPublicKeyBytes()),
|
|
725
|
+
{
|
|
726
|
+
id: spk.id(),
|
|
727
|
+
pubKey: Buffer.from(spk.publicKey().getPublicKeyBytes()),
|
|
728
|
+
signature: Buffer.from(spk.signature()),
|
|
729
|
+
},
|
|
730
|
+
preKeys.map(pk => ({
|
|
731
|
+
id: pk.id,
|
|
732
|
+
pubKey: Buffer.from(pk.record.publicKey().getPublicKeyBytes()),
|
|
733
|
+
}))
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
await this.getSocket()?.sendFrame(payload);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
public sendAck(node: Node) {
|
|
740
|
+
const socket = this.getSocket();
|
|
741
|
+
if (!socket) return;
|
|
742
|
+
|
|
743
|
+
const attrs: Record<string, any> = {
|
|
744
|
+
class: node.tag,
|
|
745
|
+
id: node.attrs.id,
|
|
746
|
+
to: node.attrs.from,
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
if (node.attrs.participant) attrs.participant = node.attrs.participant;
|
|
750
|
+
if (node.attrs.recipient) attrs.recipient = node.attrs.recipient;
|
|
751
|
+
if (node.tag !== "message" && node.attrs.type) attrs.type = node.attrs.type;
|
|
752
|
+
|
|
753
|
+
const ackNode = encodeNode("ack", attrs, undefined);
|
|
754
|
+
socket.sendFrame(marshal(ackNode)).catch(() => {});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private async sendCleanIQ(type: string, timestamp: string): Promise<void> {
|
|
758
|
+
const socket = this.getSocket();
|
|
759
|
+
if (!socket) return;
|
|
760
|
+
const id = `clean-${now()}`;
|
|
761
|
+
const cleanIQ = encodeIQ({ id, to: "s.whatsapp.net", type: "set", xmlns: "urn:xmpp:whatsapp:dirty" }, [
|
|
762
|
+
encodeNode("clean", { type, timestamp }, undefined)
|
|
763
|
+
]);
|
|
764
|
+
await socket.sendFrame(marshal(cleanIQ));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private normalizeE2EEMessage(appMessage: any, senderJid: string, chatJid: string, messageId: string, messageApp?: any): any {
|
|
768
|
+
const payload = appMessage?.payload;
|
|
769
|
+
if (!payload) return null;
|
|
770
|
+
const senderId = this.parseMessengerJid(senderJid).user;
|
|
771
|
+
const common = {
|
|
772
|
+
chatJid: chatJid,
|
|
773
|
+
senderJid: senderJid,
|
|
774
|
+
senderId: senderId,
|
|
775
|
+
threadId: chatJid,
|
|
776
|
+
messageId: messageId,
|
|
777
|
+
timestampMs: now(),
|
|
778
|
+
replyToId: messageApp?.metadata?.quotedMessage?.stanzaID,
|
|
779
|
+
replyToSenderJid: messageApp?.metadata?.quotedMessage?.remoteJID || messageApp?.metadata?.quotedMessage?.participant,
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const applicationData = payload.applicationData;
|
|
783
|
+
if (applicationData?.revoke) {
|
|
784
|
+
return {
|
|
785
|
+
...common,
|
|
786
|
+
kind: "revoke",
|
|
787
|
+
targetId: applicationData.revoke.key?.ID || applicationData.revoke.targetMessageID,
|
|
788
|
+
fromMe: applicationData.revoke.key?.fromMe,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const content = payload.content;
|
|
793
|
+
if (!content) return null;
|
|
794
|
+
|
|
795
|
+
if (content.messageText) return { ...common, kind: "text", text: content.messageText.text };
|
|
796
|
+
if (content.extendedTextMessage) return { ...common, kind: "text", text: content.extendedTextMessage.text?.text, extended: content.extendedTextMessage };
|
|
797
|
+
if (content.imageMessage) return { ...common, kind: "image", media: content.imageMessage };
|
|
798
|
+
if (content.videoMessage) return { ...common, kind: "video", media: content.videoMessage };
|
|
799
|
+
if (content.audioMessage) return { ...common, kind: "audio", media: content.audioMessage };
|
|
800
|
+
if (content.documentMessage) return { ...common, kind: "document", media: content.documentMessage };
|
|
801
|
+
if (content.stickerMessage) return { ...common, kind: "sticker", media: content.stickerMessage };
|
|
802
|
+
|
|
803
|
+
if (content.reactionMessage) {
|
|
804
|
+
return {
|
|
805
|
+
...common,
|
|
806
|
+
kind: "reaction",
|
|
807
|
+
emoji: content.reactionMessage.text,
|
|
808
|
+
targetId: content.reactionMessage.key?.ID || content.reactionMessage.targetMessageID
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (content.editMessage) {
|
|
813
|
+
return {
|
|
814
|
+
...common,
|
|
815
|
+
kind: "edit",
|
|
816
|
+
text: content.editMessage.message?.text || content.editMessage.messageText?.text,
|
|
817
|
+
targetId: content.editMessage.key?.ID || content.editMessage.targetMessageID
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (content.revokeMessage) {
|
|
822
|
+
return {
|
|
823
|
+
...common,
|
|
824
|
+
kind: "revoke",
|
|
825
|
+
targetId: content.revokeMessage.key?.ID || content.revokeMessage.targetMessageID,
|
|
826
|
+
fromMe: content.revokeMessage.key?.fromMe
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return { ...common, kind: "unknown", raw: content };
|
|
831
|
+
}
|
|
832
|
+
}
|