fb-messenger-e2ee 0.1.1 → 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.
- package/README.md +27 -2
- package/dist/index.cjs +8811 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +885 -0
- package/dist/index.d.ts +885 -0
- package/dist/index.js +8843 -0
- package/dist/index.js.map +1 -0
- package/package.json +19 -4
- package/bun.lock +0 -1014
- package/examples/basic-usage.ts +0 -52
- package/jest.config.ts +0 -18
- package/src/config/env.ts +0 -15
- package/src/controllers/client.controller.ts +0 -805
- package/src/controllers/dgw-handler.ts +0 -171
- package/src/controllers/e2ee-handler.ts +0 -832
- package/src/controllers/event-mapper.ts +0 -327
- package/src/core/client.ts +0 -151
- package/src/e2ee/application/e2ee-client.ts +0 -376
- package/src/e2ee/application/fanout-planner.ts +0 -79
- package/src/e2ee/application/outbound-message-cache.ts +0 -58
- package/src/e2ee/application/prekey-maintenance.ts +0 -55
- package/src/e2ee/application/retry-manager.ts +0 -156
- package/src/e2ee/facebook/facebook-protocol-utils.ts +0 -230
- package/src/e2ee/facebook/icdc-payload.ts +0 -31
- package/src/e2ee/index.ts +0 -76
- package/src/e2ee/internal.ts +0 -27
- package/src/e2ee/media/media-crypto.ts +0 -147
- package/src/e2ee/media/media-upload.ts +0 -90
- package/src/e2ee/message/builders/client-payload.ts +0 -54
- package/src/e2ee/message/builders/consumer-application.ts +0 -362
- package/src/e2ee/message/builders/message-application.ts +0 -64
- package/src/e2ee/message/builders/message-transport.ts +0 -84
- package/src/e2ee/message/codecs/protobuf-codecs.ts +0 -101
- package/src/e2ee/message/constants.ts +0 -4
- package/src/e2ee/message/message-builder.ts +0 -15
- package/src/e2ee/signal/prekey-manager.ts +0 -140
- package/src/e2ee/signal/signal-manager.ts +0 -365
- package/src/e2ee/store/device-json.ts +0 -47
- package/src/e2ee/store/device-repository.ts +0 -12
- package/src/e2ee/store/device-store.ts +0 -538
- package/src/e2ee/transport/binary/decoder.ts +0 -180
- package/src/e2ee/transport/binary/encoder.ts +0 -143
- package/src/e2ee/transport/binary/stanzas.ts +0 -97
- package/src/e2ee/transport/binary/tokens.ts +0 -64
- package/src/e2ee/transport/binary/wa-binary.ts +0 -8
- package/src/e2ee/transport/dgw/dgw-socket.ts +0 -345
- package/src/e2ee/transport/noise/noise-handshake.ts +0 -417
- package/src/e2ee/transport/noise/noise-socket.ts +0 -230
- package/src/index.ts +0 -34
- package/src/models/client.ts +0 -26
- package/src/models/config.ts +0 -14
- package/src/models/domain.ts +0 -299
- package/src/models/e2ee.ts +0 -295
- package/src/models/media.ts +0 -41
- package/src/models/messaging.ts +0 -88
- package/src/models/thread.ts +0 -69
- package/src/repositories/session.repository.ts +0 -20
- package/src/services/auth.service.ts +0 -55
- package/src/services/e2ee.service.ts +0 -174
- package/src/services/facebook-gateway.service.ts +0 -245
- package/src/services/icdc.service.ts +0 -177
- package/src/services/media.service.ts +0 -304
- package/src/services/messaging.service.ts +0 -28
- package/src/services/thread.service.ts +0 -199
- package/src/types/advanced-types.ts +0 -61
- package/src/types/fca-unofficial.d.ts +0 -212
- package/src/types/protocol-types.ts +0 -43
- package/src/utils/fca-utils.ts +0 -30
- package/src/utils/logger.ts +0 -24
- package/src/utils/mime.ts +0 -51
- package/tests/.env.example +0 -87
- package/tests/data/1x1.png +0 -0
- package/tests/data/example.txt +0 -1
- package/tests/data/file_example.mp3 +0 -0
- package/tests/data/file_example.mp4 +0 -0
- package/tests/integration.test.ts +0 -498
- package/tests/script/echo-e2ee.ts +0 -174
- package/tests/script/send-e2ee-media.ts +0 -227
- package/tests/script/send-e2ee-reaction.ts +0 -108
- package/tests/script/send-e2ee-unsend.ts +0 -115
- package/tests/script/send-typing.ts +0 -107
- package/tests/script/test-group-send.ts +0 -105
- package/tests/setup.ts +0 -3
- package/tests/types.test.ts +0 -57
- package/tests/unit/controllers/dgw-handler.test.ts +0 -60
- package/tests/unit/controllers/e2ee-handler.test.ts +0 -293
- package/tests/unit/controllers/event-mapper.test.ts +0 -252
- package/tests/unit/e2ee/application-helpers.test.ts +0 -418
- package/tests/unit/e2ee/device-store.test.ts +0 -126
- package/tests/unit/e2ee/facebook-protocol-utils.test.ts +0 -134
- package/tests/unit/e2ee/media-crypto.test.ts +0 -55
- package/tests/unit/e2ee/media-upload.test.ts +0 -60
- package/tests/unit/e2ee/message-builder.test.ts +0 -42
- package/tests/unit/e2ee/message-builders.test.ts +0 -230
- package/tests/unit/e2ee/noise-handshake.test.ts +0 -260
- package/tests/unit/e2ee/prekey-manager.test.ts +0 -55
- package/tests/unit/e2ee/wa-binary.test.ts +0 -127
- package/tests/unit/services/e2ee.service.test.ts +0 -101
- package/tests/unit/services/facebook-gateway.service.test.ts +0 -138
- package/tests/unit/services/media.service.test.ts +0 -169
- package/tests/unit/utils/fca-utils.test.ts +0 -48
- package/tsconfig.json +0 -19
- /package/{src/e2ee/message/proto → proto}/ArmadilloApplication.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/ArmadilloICDC.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/ConsumerApplication.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/MessageApplication.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/MessageTransport.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/WACommon.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/WAMediaTransport.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/proto-writer.ts +0 -0
|
@@ -1,417 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* E2EE Noise Handshake - Layer 1 (Transport)
|
|
3
|
-
*
|
|
4
|
-
* Implements Noise_XX_25519_AESGCM_SHA256 - the transport layer handshake
|
|
5
|
-
* used by WhatsApp/Messenger before any Signal Protocol messages.
|
|
6
|
-
*
|
|
7
|
-
* Flow:
|
|
8
|
-
* 1. Client -> Server: ClientHello { ephemeral: EphemeralPub }
|
|
9
|
-
* 2. Server -> Client: ServerHello { ephemeral, static(enc), payload(enc=cert) }
|
|
10
|
-
* 3. Client -> Server: ClientFinish { static(enc=NoiseKey.Pub), payload(enc=ClientPayload) }
|
|
11
|
-
* -> Both sides derive sendKey + recvKey for AES-256-GCM frame encryption
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
createCipheriv,
|
|
16
|
-
createDecipheriv,
|
|
17
|
-
createHash,
|
|
18
|
-
hkdfSync,
|
|
19
|
-
randomBytes,
|
|
20
|
-
generateKeyPairSync,
|
|
21
|
-
createPublicKey,
|
|
22
|
-
createPrivateKey,
|
|
23
|
-
diffieHellman,
|
|
24
|
-
} from "node:crypto";
|
|
25
|
-
import { inflateSync } from "node:zlib";
|
|
26
|
-
import type { HandshakeResult, NoiseSocket, RawWebSocket } from "../../../models/e2ee.ts";
|
|
27
|
-
import { logger } from "../../../utils/logger.ts";
|
|
28
|
-
export type { HandshakeResult, NoiseSocket, RawWebSocket };
|
|
29
|
-
|
|
30
|
-
// X25519 Helpers using node:crypto
|
|
31
|
-
const SPKI_HEADER = Buffer.from("302a300506032b656e032100", "hex");
|
|
32
|
-
const PKCS8_HEADER = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
33
|
-
|
|
34
|
-
export function generateX25519(): { priv: Buffer; pub: Buffer } {
|
|
35
|
-
const { privateKey, publicKey } = generateKeyPairSync("x25519");
|
|
36
|
-
const privRaw = privateKey.export({ type: "pkcs8", format: "der" });
|
|
37
|
-
const pubRaw = publicKey.export({ type: "spki", format: "der" });
|
|
38
|
-
return {
|
|
39
|
-
priv: Buffer.from(privRaw.subarray(privRaw.length - 32)),
|
|
40
|
-
pub: Buffer.from(pubRaw.subarray(pubRaw.length - 32)),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function getX25519PublicKey(privKeyRaw: Buffer): Buffer {
|
|
45
|
-
const priv = createPrivateKey({ key: Buffer.concat([PKCS8_HEADER, privKeyRaw]), format: "der", type: "pkcs8" });
|
|
46
|
-
const pubDer = createPublicKey(priv).export({ type: "spki", format: "der" });
|
|
47
|
-
return Buffer.from(pubDer.subarray(pubDer.length - 32));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function x25519DH(privKeyRaw: Buffer, pubKeyRaw: Buffer): Buffer {
|
|
51
|
-
const pub = createPublicKey({ key: Buffer.concat([SPKI_HEADER, pubKeyRaw]), format: "der", type: "spki" });
|
|
52
|
-
const priv = createPrivateKey({ key: Buffer.concat([PKCS8_HEADER, privKeyRaw]), format: "der", type: "pkcs8" });
|
|
53
|
-
return diffieHellman({ privateKey: priv, publicKey: pub });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Hardcoded protocol constants
|
|
57
|
-
|
|
58
|
-
// Root certificate public key - Ed25519, 32 bytes
|
|
59
|
-
export const WA_CERT_PUB_KEY = Buffer.from(
|
|
60
|
-
"142375574d0a587166aae71ebe516437c4a28b73e3695c6ce1f7f9545da8ee6b",
|
|
61
|
-
"hex",
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
// Noise handshake protocol name (WA variant)
|
|
65
|
-
const NOISE_START_PATTERN = Buffer.from("Noise_XX_25519_AESGCM_SHA256\0\0\0\0");
|
|
66
|
-
|
|
67
|
-
// WA header sent at the start of every connection (version + magic bytes)
|
|
68
|
-
export const WA_HEADER = Buffer.from([87, 65, 6, 3]); // "WA\x06\x03"
|
|
69
|
-
|
|
70
|
-
// Noise state machine helpers
|
|
71
|
-
|
|
72
|
-
class NoiseHandshakeState {
|
|
73
|
-
private h: Buffer; // hash (chaining key / handshake hash)
|
|
74
|
-
private ck: Buffer; // chaining key
|
|
75
|
-
private k: Buffer | null = null; // current symmetric key
|
|
76
|
-
private n = 0; // nonce counter
|
|
77
|
-
|
|
78
|
-
constructor(startPattern: Buffer, header: Buffer) {
|
|
79
|
-
// If protocol_name is exactly 32 bytes, h = protocol_name
|
|
80
|
-
if (startPattern.length === 32) {
|
|
81
|
-
this.h = Buffer.from(startPattern);
|
|
82
|
-
} else {
|
|
83
|
-
this.h = this.sha256(startPattern);
|
|
84
|
-
}
|
|
85
|
-
this.ck = this.h;
|
|
86
|
-
// h = SHA256(h ‖ prologue)
|
|
87
|
-
this.mixHash(header);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private sha256(data: Buffer): Buffer {
|
|
91
|
-
return Buffer.from(createHash("sha256").update(data).digest());
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
mixHash(data: Buffer): void {
|
|
95
|
-
this.h = this.sha256(Buffer.concat([this.h, data]));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
mixKey(input: Buffer): void {
|
|
99
|
-
// HKDF-SHA256(ck, input) -> ck', k
|
|
100
|
-
const expanded = Buffer.from(hkdfSync("sha256", input, this.ck, "", 64));
|
|
101
|
-
this.ck = expanded.subarray(0, 32);
|
|
102
|
-
this.k = expanded.subarray(32, 64);
|
|
103
|
-
this.n = 0;
|
|
104
|
-
// logger.debug("noise-handshake", `mixKey -> ck=${this.ck.toString("hex").slice(0, 8)} k=${this.k.toString("hex").slice(0, 8)}`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
mixSharedSecretIntoKey(privKey: Buffer, pubKey: Buffer): void {
|
|
108
|
-
const sharedSecret = x25519DH(privKey, pubKey);
|
|
109
|
-
this.mixKey(sharedSecret);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** AES-256-GCM encrypt; ad = current h; updates h */
|
|
113
|
-
encrypt(plaintext: Buffer): Buffer {
|
|
114
|
-
if (!this.k) throw new Error("No key set in Noise state");
|
|
115
|
-
const nonce = this.buildNonce(this.n++);
|
|
116
|
-
const cipher = createCipheriv("aes-256-gcm", this.k, nonce);
|
|
117
|
-
cipher.setAAD(this.h);
|
|
118
|
-
const enc = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
119
|
-
this.mixHash(enc);
|
|
120
|
-
return enc;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** AES-256-GCM decrypt; ad = current h; updates h */
|
|
124
|
-
decrypt(ciphertext: Buffer): Buffer {
|
|
125
|
-
if (!this.k) throw new Error("No key set in Noise state");
|
|
126
|
-
const nonce = this.buildNonce(this.n++);
|
|
127
|
-
const tag = ciphertext.subarray(-16);
|
|
128
|
-
const body = ciphertext.subarray(0, -16);
|
|
129
|
-
try {
|
|
130
|
-
const decipher = createDecipheriv("aes-256-gcm", this.k, nonce);
|
|
131
|
-
decipher.setAAD(this.h);
|
|
132
|
-
decipher.setAuthTag(tag);
|
|
133
|
-
const plain = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
134
|
-
this.mixHash(ciphertext);
|
|
135
|
-
return plain;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
logger.error("CipherState", `Decrypt error at n=${this.n - 1}:`, err);
|
|
138
|
-
logger.error("CipherState", ` - Key: ${this.k.toString('hex')}`);
|
|
139
|
-
logger.error("CipherState", ` - Nonce: ${nonce.toString('hex')}`);
|
|
140
|
-
logger.error("CipherState", ` - AAD: ${this.h.toString('hex')}`);
|
|
141
|
-
throw err;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Derive final send/recv keys from the completed handshake */
|
|
146
|
-
finish(): { sendKey: Buffer; recvKey: Buffer } {
|
|
147
|
-
const expanded = Buffer.from(hkdfSync("sha256", Buffer.alloc(0), this.ck, Buffer.alloc(0), 64));
|
|
148
|
-
return {
|
|
149
|
-
sendKey: expanded.subarray(0, 32),
|
|
150
|
-
recvKey: expanded.subarray(32, 64),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
get handshakeHash(): Buffer { return this.h; }
|
|
155
|
-
|
|
156
|
-
private buildNonce(counter: number): Buffer {
|
|
157
|
-
const nonce = Buffer.alloc(12);
|
|
158
|
-
nonce.writeUInt32BE(counter, 8);
|
|
159
|
-
return nonce;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function verifyCertChain(certChainRaw: Buffer, serverStaticPub: Buffer): void {
|
|
164
|
-
// Placeholder - full cert chain verification requires waCert proto parser.
|
|
165
|
-
// Trust established via Noise shared-secret derivation instead.
|
|
166
|
-
void certChainRaw;
|
|
167
|
-
void serverStaticPub;
|
|
168
|
-
// TODO: Implement full cert chain verification in production
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Noise handshake + frame socket
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
/** Encrypted frame socket backed by established Noise keys */
|
|
175
|
-
class EncryptedFrameSocket implements NoiseSocket {
|
|
176
|
-
private ws: RawWebSocket;
|
|
177
|
-
private sendKey: Buffer;
|
|
178
|
-
private recvKey: Buffer;
|
|
179
|
-
private sendCounter: number = 0;
|
|
180
|
-
private recvCounter: number = 0;
|
|
181
|
-
|
|
182
|
-
constructor(ws: RawWebSocket, sendKey: Buffer, recvKey: Buffer) {
|
|
183
|
-
this.ws = ws;
|
|
184
|
-
this.sendKey = sendKey;
|
|
185
|
-
this.recvKey = recvKey;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private buildNonce(counter: number): Buffer {
|
|
189
|
-
const n = Buffer.alloc(12);
|
|
190
|
-
n.writeBigUInt64BE(BigInt(counter), 4);
|
|
191
|
-
return n;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private encryptFrame(plaintext: Buffer): Buffer {
|
|
195
|
-
const nonce = this.buildNonce(this.sendCounter++);
|
|
196
|
-
const cipher = createCipheriv("aes-256-gcm", this.sendKey, nonce);
|
|
197
|
-
const enc = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
198
|
-
|
|
199
|
-
// Prefix with 3-byte big-endian length
|
|
200
|
-
const header = Buffer.alloc(3);
|
|
201
|
-
header.writeUIntBE(enc.length, 0, 3);
|
|
202
|
-
|
|
203
|
-
const fullFrame = Buffer.concat([header, enc]);
|
|
204
|
-
logger.debug("noise-handshake", `Sending frame (${fullFrame.length} bytes): ${fullFrame.toString("hex").slice(0, 32)}...`);
|
|
205
|
-
return fullFrame;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
private decryptFrame(data: Buffer): Buffer {
|
|
209
|
-
if (data.length <= 16) {
|
|
210
|
-
// Keep-alive or too short to be an encrypted frame
|
|
211
|
-
return Buffer.alloc(0);
|
|
212
|
-
}
|
|
213
|
-
const nonce = this.buildNonce(this.recvCounter++);
|
|
214
|
-
const tag = data.subarray(-16);
|
|
215
|
-
const body = data.subarray(0, -16);
|
|
216
|
-
const decipher = createDecipheriv("aes-256-gcm", this.recvKey, nonce);
|
|
217
|
-
decipher.setAuthTag(tag);
|
|
218
|
-
|
|
219
|
-
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async sendFrame(data: Buffer): Promise<void> {
|
|
223
|
-
this.ws.send(this.encryptFrame(data));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async readFrame(): Promise<Buffer> {
|
|
227
|
-
const header = await this.ws.readRaw(3);
|
|
228
|
-
if (!header) {
|
|
229
|
-
throw new Error("Socket closed while reading frame header");
|
|
230
|
-
}
|
|
231
|
-
const len = header.readUIntBE(0, 3);
|
|
232
|
-
logger.debug("FacebookE2EESocket", `RAW frame header: ${header.toString('hex')} (len=${len})`);
|
|
233
|
-
|
|
234
|
-
const payload = await this.ws.readRaw(len);
|
|
235
|
-
try {
|
|
236
|
-
const decrypted = this.decryptFrame(payload);
|
|
237
|
-
logger.debug("FacebookE2EESocket", `Decrypt successful, result length: ${decrypted.length}`);
|
|
238
|
-
return decrypted;
|
|
239
|
-
} catch (err) {
|
|
240
|
-
logger.error("FacebookE2EESocket", "Decrypt FAILED:", err);
|
|
241
|
-
throw err;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
close(): void { this.ws.close(); }
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Public API: doHandshake
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Perform the Noise XX handshake with the WhatsApp/Messenger server.
|
|
253
|
-
*
|
|
254
|
-
* @param ws Raw WebSocket (caller handles connection)
|
|
255
|
-
* @param noiseKeyPriv 32-byte Noise private key from DeviceStore
|
|
256
|
-
* @param clientPayload Serialized ClientPayload protobuf (device identity)
|
|
257
|
-
*/
|
|
258
|
-
export async function doHandshake(
|
|
259
|
-
ws: RawWebSocket,
|
|
260
|
-
noiseKeyPriv: Buffer,
|
|
261
|
-
clientPayload: Buffer,
|
|
262
|
-
): Promise<HandshakeResult> {
|
|
263
|
-
const state = new NoiseHandshakeState(NOISE_START_PATTERN, WA_HEADER);
|
|
264
|
-
|
|
265
|
-
// Generate ephemeral X25519 keypair using node:crypto
|
|
266
|
-
const eph = generateX25519();
|
|
267
|
-
const ephPriv = eph.priv;
|
|
268
|
-
const ephPub = eph.pub;
|
|
269
|
-
logger.debug("debug", `ClientEphPriv: ${ephPriv.toString('hex')}`);
|
|
270
|
-
logger.debug("debug", `ClientEphPub: ${ephPub.toString('hex')}`);
|
|
271
|
-
|
|
272
|
-
state.mixHash(ephPub);
|
|
273
|
-
|
|
274
|
-
// Step 1: Send ClientHello
|
|
275
|
-
// Proto: HandshakeMessage { clientHello: { ephemeral: ephPub } }
|
|
276
|
-
const clientHello = encodeHandshakeMessage({ clientHello: { ephemeral: ephPub } });
|
|
277
|
-
ws.send(prependHeader(clientHello));
|
|
278
|
-
|
|
279
|
-
// Step 2: Receive ServerHello
|
|
280
|
-
const serverHelloRaw = await readRawFrame(ws);
|
|
281
|
-
const serverHello = decodeServerHello(serverHelloRaw);
|
|
282
|
-
|
|
283
|
-
const serverEphPub = Buffer.from(serverHello.ephemeral);
|
|
284
|
-
const serverStaticEnc = Buffer.from(serverHello.static);
|
|
285
|
-
logger.debug("debug", `ServerStaticEnc: ${serverStaticEnc.toString('hex')}`);
|
|
286
|
-
const certEnc = Buffer.from(serverHello.payload);
|
|
287
|
-
|
|
288
|
-
logger.debug("noise-handshake", `Eph: ${serverEphPub.length}, StaticEnc: ${serverStaticEnc.length}, CertEnc: ${certEnc.length}`);
|
|
289
|
-
|
|
290
|
-
logger.debug("debug", `ServerEphPub: ${serverEphPub.toString('hex')}`);
|
|
291
|
-
state.mixHash(serverEphPub);
|
|
292
|
-
state.mixSharedSecretIntoKey(ephPriv, serverEphPub);
|
|
293
|
-
logger.debug("debug", `After ServerHello mix: k=${state['k']?.toString('hex')}, h=${state['h']?.toString('hex')}`);
|
|
294
|
-
|
|
295
|
-
const serverStaticPub = state.decrypt(serverStaticEnc);
|
|
296
|
-
logger.debug("noise-handshake", `Decrypted Server Static Pub: ${serverStaticPub.toString("hex")}`);
|
|
297
|
-
state.mixSharedSecretIntoKey(ephPriv, serverStaticPub);
|
|
298
|
-
|
|
299
|
-
const certDecrypted = state.decrypt(certEnc);
|
|
300
|
-
verifyCertChain(certDecrypted, serverStaticPub);
|
|
301
|
-
|
|
302
|
-
// Step 3: Send ClientFinish
|
|
303
|
-
const noiseKeyPub = getX25519PublicKey(noiseKeyPriv);
|
|
304
|
-
const encNoisePub = state.encrypt(noiseKeyPub);
|
|
305
|
-
state.mixSharedSecretIntoKey(noiseKeyPriv, serverEphPub);
|
|
306
|
-
|
|
307
|
-
const encPayload = state.encrypt(clientPayload);
|
|
308
|
-
const clientFinish = encodeHandshakeMessage({
|
|
309
|
-
clientFinish: { static: encNoisePub, payload: encPayload },
|
|
310
|
-
});
|
|
311
|
-
const finishFrame = prependLength(clientFinish);
|
|
312
|
-
logger.debug("noise-handshake", `Sending clientFinish frame (${finishFrame.length} bytes): ${finishFrame.toString("hex").slice(0, 32)}...`);
|
|
313
|
-
ws.send(finishFrame);
|
|
314
|
-
|
|
315
|
-
// Derive final keys
|
|
316
|
-
const { sendKey, recvKey } = state.finish();
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
socket: new EncryptedFrameSocket(ws, sendKey, recvKey),
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Minimal protobuf helpers for handshake messages
|
|
324
|
-
|
|
325
|
-
function encodeHandshakeMessage(msg: {
|
|
326
|
-
clientHello?: { ephemeral: Buffer };
|
|
327
|
-
clientFinish?: { static: Buffer; payload: Buffer };
|
|
328
|
-
}): Buffer {
|
|
329
|
-
// HandshakeMessage { field 2 = ClientHello { field 1 = ephemeral } }
|
|
330
|
-
// HandshakeMessage { field 4 = ClientFinish { field 1 = static, field 3 = payload } }
|
|
331
|
-
// Note: ClientFinish payload is field 3, not 2! (Wait, let me double check waProto.proto)
|
|
332
|
-
// Let me output the fix for HandshakeMessage first.
|
|
333
|
-
const chunks: Buffer[] = [];
|
|
334
|
-
|
|
335
|
-
if (msg.clientHello) {
|
|
336
|
-
const hello = encLenDelim(1, msg.clientHello.ephemeral);
|
|
337
|
-
chunks.push(encLenDelim(2, hello));
|
|
338
|
-
}
|
|
339
|
-
if (msg.clientFinish) {
|
|
340
|
-
const finish = Buffer.concat([
|
|
341
|
-
encLenDelim(1, msg.clientFinish.static),
|
|
342
|
-
encLenDelim(2, msg.clientFinish.payload),
|
|
343
|
-
]);
|
|
344
|
-
chunks.push(encLenDelim(4, finish));
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return Buffer.concat(chunks);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function decodeServerHello(data: Buffer): {
|
|
351
|
-
ephemeral: Buffer;
|
|
352
|
-
static: Buffer;
|
|
353
|
-
payload: Buffer;
|
|
354
|
-
} {
|
|
355
|
-
// HandshakeMessage { field 3 = ServerHello { field 1 = ephemeral, field 2 = static, field 3 = payload } }
|
|
356
|
-
const serverHelloRaw = decodeLenDelim(data, 3);
|
|
357
|
-
return {
|
|
358
|
-
ephemeral: decodeLenDelim(serverHelloRaw, 1),
|
|
359
|
-
static: decodeLenDelim(serverHelloRaw, 2),
|
|
360
|
-
payload: decodeLenDelim(serverHelloRaw, 3),
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Tiny protobuf encoder helpers
|
|
365
|
-
function encVarint(n: number): Buffer {
|
|
366
|
-
const bytes: number[] = [];
|
|
367
|
-
let v = n >>> 0;
|
|
368
|
-
while (v > 127) { bytes.push((v & 0x7f) | 0x80); v >>>= 7; }
|
|
369
|
-
bytes.push(v);
|
|
370
|
-
return Buffer.from(bytes);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function encLenDelim(field: number, data: Buffer): Buffer {
|
|
374
|
-
return Buffer.concat([encVarint((field << 3) | 2), encVarint(data.length), data]);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function decodeLenDelim(data: Buffer, targetField: number): Buffer {
|
|
378
|
-
let pos = 0;
|
|
379
|
-
while (pos < data.length) {
|
|
380
|
-
const tag = data[pos]!; pos++;
|
|
381
|
-
const field = tag >> 3;
|
|
382
|
-
const wire = tag & 0x07;
|
|
383
|
-
if (wire === 2) {
|
|
384
|
-
let len = 0, shift = 0;
|
|
385
|
-
while (true) {
|
|
386
|
-
const b = data[pos]!; pos++;
|
|
387
|
-
len |= (b & 0x7f) << shift;
|
|
388
|
-
if (!(b & 0x80)) break;
|
|
389
|
-
shift += 7;
|
|
390
|
-
}
|
|
391
|
-
const value = data.subarray(pos, pos + len); pos += len;
|
|
392
|
-
if (field === targetField) return Buffer.from(value);
|
|
393
|
-
} else if (wire === 0) {
|
|
394
|
-
while (pos < data.length && (data[pos]! & 0x80)) pos++;
|
|
395
|
-
pos++;
|
|
396
|
-
} else break;
|
|
397
|
-
}
|
|
398
|
-
throw new Error(`Field ${targetField} not found in handshake message`);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function prependHeader(data: Buffer): Buffer {
|
|
402
|
-
return Buffer.concat([WA_HEADER, prependLength(data)]);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function prependLength(data: Buffer): Buffer {
|
|
406
|
-
const header = Buffer.alloc(3);
|
|
407
|
-
header.writeUIntBE(data.length, 0, 3);
|
|
408
|
-
return Buffer.concat([header, data]);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async function readRawFrame(ws: RawWebSocket): Promise<Buffer> {
|
|
412
|
-
const header = await ws.readRaw(3);
|
|
413
|
-
const len = header.readUIntBE(0, 3);
|
|
414
|
-
const payload = await ws.readRaw(len);
|
|
415
|
-
logger.debug("noise-handshake", `Raw Frame received: header=${header.toString('hex')} (len=${len}), payload_head=${payload.toString('hex').slice(0, 32)}...`);
|
|
416
|
-
return payload;
|
|
417
|
-
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
|
-
import { appendFileSync } from "node:fs";
|
|
3
|
-
import { doHandshake } from "./noise-handshake.ts";
|
|
4
|
-
import type { NoiseSocket, RawWebSocket } from "../../../models/e2ee.ts";
|
|
5
|
-
import { encodeKeepAlive, unmarshal } from "../binary/wa-binary.ts";
|
|
6
|
-
import { logger } from "../../../utils/logger.ts";
|
|
7
|
-
|
|
8
|
-
export class FacebookE2EESocket extends EventEmitter {
|
|
9
|
-
private ws: WebSocket | null = null;
|
|
10
|
-
private noiseSocket: NoiseSocket | null = null;
|
|
11
|
-
private url: string;
|
|
12
|
-
private heartbeatInterval: any = null;
|
|
13
|
-
private isConnected: boolean = false;
|
|
14
|
-
|
|
15
|
-
constructor(endpoint: string) {
|
|
16
|
-
super();
|
|
17
|
-
this.url = endpoint;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
public async connect(noisePrivKey: Buffer, authPayload: Buffer): Promise<void> {
|
|
21
|
-
return new Promise((resolve, reject) => {
|
|
22
|
-
try {
|
|
23
|
-
const wsUrl = new URL(this.url);
|
|
24
|
-
// Add chat specific query params if needed
|
|
25
|
-
wsUrl.searchParams.set("cid", "client-" + Date.now());
|
|
26
|
-
|
|
27
|
-
const UserAgentStr = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
|
|
28
|
-
|
|
29
|
-
this.ws = new (WebSocket as any)(wsUrl.toString(), undefined, {
|
|
30
|
-
headers: {
|
|
31
|
-
"Origin": "https://www.facebook.com",
|
|
32
|
-
"User-Agent": UserAgentStr,
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
this.ws!.binaryType = "arraybuffer";
|
|
36
|
-
|
|
37
|
-
let handshakeResolved = false;
|
|
38
|
-
let streamBuffer: Buffer[] = [];
|
|
39
|
-
let streamLen = 0;
|
|
40
|
-
let waitingResolver: ((data: Buffer) => void) | null = null;
|
|
41
|
-
let waitingLen: number = 0;
|
|
42
|
-
|
|
43
|
-
this.ws!.addEventListener("message", (ev) => {
|
|
44
|
-
const frame = Buffer.from(ev.data as ArrayBuffer);
|
|
45
|
-
streamBuffer.push(frame);
|
|
46
|
-
streamLen += frame.length;
|
|
47
|
-
if (waitingResolver && streamLen >= waitingLen) {
|
|
48
|
-
const res = waitingResolver;
|
|
49
|
-
const len = waitingLen;
|
|
50
|
-
waitingResolver = null;
|
|
51
|
-
waitingLen = 0;
|
|
52
|
-
res(readFromBuffer(len));
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
function readFromBuffer(len: number): Buffer {
|
|
57
|
-
let res = Buffer.alloc(len);
|
|
58
|
-
let offset = 0;
|
|
59
|
-
while (offset < len && streamBuffer.length > 0) {
|
|
60
|
-
const first = streamBuffer[0];
|
|
61
|
-
const remaining = len - offset;
|
|
62
|
-
if (first && first.length <= remaining) {
|
|
63
|
-
first.copy(res, offset);
|
|
64
|
-
offset += first.length;
|
|
65
|
-
streamBuffer.shift();
|
|
66
|
-
} else if (first) {
|
|
67
|
-
first.copy(res, offset, 0, remaining);
|
|
68
|
-
streamBuffer[0] = first.subarray(remaining);
|
|
69
|
-
offset += remaining;
|
|
70
|
-
} else {
|
|
71
|
-
break;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
streamLen -= len;
|
|
75
|
-
return res;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const rawWs: RawWebSocket = {
|
|
79
|
-
send: (data: Buffer) => {
|
|
80
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
81
|
-
this.ws.send(data);
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
readRaw: (len?: number): Promise<Buffer> => {
|
|
85
|
-
const targetLen = len || 0;
|
|
86
|
-
if (targetLen === 0) {
|
|
87
|
-
if (streamLen > 0) return Promise.resolve(readFromBuffer(streamLen));
|
|
88
|
-
return new Promise((resolve) => {
|
|
89
|
-
waitingLen = 1;
|
|
90
|
-
waitingResolver = () => resolve(readFromBuffer(streamLen));
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
if (streamLen >= targetLen) {
|
|
94
|
-
return Promise.resolve(readFromBuffer(targetLen));
|
|
95
|
-
}
|
|
96
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
97
|
-
return Promise.reject(new Error("WebSocket not open"));
|
|
98
|
-
}
|
|
99
|
-
return new Promise((resolve) => {
|
|
100
|
-
waitingLen = targetLen;
|
|
101
|
-
waitingResolver = resolve;
|
|
102
|
-
});
|
|
103
|
-
},
|
|
104
|
-
close: () => this.ws?.close(),
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
this.ws!.addEventListener("close", () => {
|
|
108
|
-
this.isConnected = false;
|
|
109
|
-
this.stopHeartbeat();
|
|
110
|
-
if (waitingResolver) {
|
|
111
|
-
waitingResolver(null as any);
|
|
112
|
-
waitingResolver = null;
|
|
113
|
-
}
|
|
114
|
-
this.emit("disconnected");
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
this.ws!.addEventListener("open", async () => {
|
|
118
|
-
try {
|
|
119
|
-
const { socket } = await doHandshake(rawWs, noisePrivKey, authPayload);
|
|
120
|
-
this.noiseSocket = socket;
|
|
121
|
-
handshakeResolved = true;
|
|
122
|
-
this.isConnected = true;
|
|
123
|
-
this.emit("connected");
|
|
124
|
-
resolve();
|
|
125
|
-
this.startReading();
|
|
126
|
-
this.startHeartbeat();
|
|
127
|
-
} catch (err) {
|
|
128
|
-
reject(err);
|
|
129
|
-
this.ws?.close();
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
this.ws!.addEventListener("error", (ev) => {
|
|
134
|
-
if (!handshakeResolved) reject(new Error("WebSocket error"));
|
|
135
|
-
this.emit("error", ev);
|
|
136
|
-
});
|
|
137
|
-
} catch (err) {
|
|
138
|
-
reject(err);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private startHeartbeat() {
|
|
144
|
-
this.stopHeartbeat();
|
|
145
|
-
this.heartbeatInterval = setInterval(async () => {
|
|
146
|
-
try {
|
|
147
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
148
|
-
logger.debug("NoiseSocket", "Sending Noise heartbeat (0,0,0)...");
|
|
149
|
-
this.ws.send(Buffer.from([0, 0, 0]));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
} catch (err) {
|
|
153
|
-
logger.error("FacebookE2EESocket", "Heartbeat failed:", err);
|
|
154
|
-
}
|
|
155
|
-
}, 15000);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private stopHeartbeat() {
|
|
159
|
-
if (this.heartbeatInterval) {
|
|
160
|
-
clearInterval(this.heartbeatInterval);
|
|
161
|
-
this.heartbeatInterval = null;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
private async startReading(): Promise<void> {
|
|
166
|
-
if (!this.noiseSocket) return;
|
|
167
|
-
try {
|
|
168
|
-
while (this.isConnected && this.noiseSocket) {
|
|
169
|
-
logger.debug("FacebookE2EESocket", "Waiting for frame...");
|
|
170
|
-
const frame = await this.noiseSocket.readFrame();
|
|
171
|
-
if (frame && frame.length > 0) {
|
|
172
|
-
try {
|
|
173
|
-
logger.debug("FacebookE2EESocket", `Unmarshaling frame (${frame.length} bytes)...`);
|
|
174
|
-
const node = unmarshal(frame);
|
|
175
|
-
logger.debug("FacebookE2EESocket", `Decrypted node: <${node.tag}>`, JSON.stringify(node.attrs, null, 2));
|
|
176
|
-
if (node.content) {
|
|
177
|
-
logger.debug("FacebookE2EESocket", `Node content type: ${Array.isArray(node.content) ? "Array[" + node.content.length + "]" : typeof node.content}`);
|
|
178
|
-
}
|
|
179
|
-
} catch (e) {
|
|
180
|
-
logger.error("FacebookE2EESocket", `Received decrypted frame (${frame.length} bytes), but failed to unmarshal: ${e}`);
|
|
181
|
-
logger.debug("FacebookE2EESocket", `Frame hex: ${frame.toString("hex").slice(0, 100)}`);
|
|
182
|
-
}
|
|
183
|
-
this.emit("frame", frame);
|
|
184
|
-
} else if (frame) {
|
|
185
|
-
// Heartbeat or empty frame from server
|
|
186
|
-
logger.debug("FacebookE2EESocket", "Received empty frame (heartbeat from server)");
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
} catch (err) {
|
|
190
|
-
if (!this.isConnected) {
|
|
191
|
-
logger.debug("FacebookE2EESocket", "Read loop stopped after intentional socket close");
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
logger.error("FacebookE2EESocket", "Read loop stopped due to error:", err);
|
|
195
|
-
this.isConnected = false;
|
|
196
|
-
this.emit("error", err);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
public async sendFrame(data: Buffer): Promise<void> {
|
|
201
|
-
if (!this.noiseSocket) throw new Error("Socket not connected");
|
|
202
|
-
try {
|
|
203
|
-
const node = unmarshal(data);
|
|
204
|
-
logger.debug("NoiseSocket", `Sending encrypted node: <${node.tag}>`, JSON.stringify(node.attrs, null, 2));
|
|
205
|
-
try {
|
|
206
|
-
const record = { ts: Date.now(), tag: node.tag, attrs: node.attrs, hex: data.toString("hex") };
|
|
207
|
-
appendFileSync("./outbound_frames.log", JSON.stringify(record) + "\n");
|
|
208
|
-
} catch (e) {
|
|
209
|
-
logger.debug("NoiseSocket", "Failed to write outbound frame log:", e);
|
|
210
|
-
}
|
|
211
|
-
} catch (e) {
|
|
212
|
-
logger.debug("NoiseSocket", `Sending raw encrypted frame (${data.length} bytes)`);
|
|
213
|
-
try {
|
|
214
|
-
const record = { ts: Date.now(), tag: null, attrs: null, hex: data.toString("hex") };
|
|
215
|
-
appendFileSync("./outbound_frames.log", JSON.stringify(record) + "\n");
|
|
216
|
-
} catch (e) {
|
|
217
|
-
logger.debug("NoiseSocket", "Failed to write outbound frame log:", e);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
await this.noiseSocket.sendFrame(data);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
public close(): void {
|
|
225
|
-
this.isConnected = false;
|
|
226
|
-
this.stopHeartbeat();
|
|
227
|
-
this.noiseSocket?.close();
|
|
228
|
-
this.ws?.close();
|
|
229
|
-
}
|
|
230
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
export { FBClient } from "./core/client.ts";
|
|
2
|
-
export { E2EEService } from "./services/e2ee.service.ts";
|
|
3
|
-
|
|
4
|
-
export type { ClientOptions, SessionData, MessengerEventMap } from "./models/client.ts";
|
|
5
|
-
export type { AuthConfig, AppEnv } from "./models/config.ts";
|
|
6
|
-
export type {
|
|
7
|
-
Attachment,
|
|
8
|
-
Mention,
|
|
9
|
-
ReplyTo,
|
|
10
|
-
Platform,
|
|
11
|
-
MessengerEvent,
|
|
12
|
-
E2EEMessage,
|
|
13
|
-
E2EEMessageKind,
|
|
14
|
-
} from "./models/domain.ts";
|
|
15
|
-
export type {
|
|
16
|
-
SendMessageInput,
|
|
17
|
-
SendMediaInput,
|
|
18
|
-
SendReactionInput,
|
|
19
|
-
TypingInput,
|
|
20
|
-
} from "./models/messaging.ts";
|
|
21
|
-
export type {
|
|
22
|
-
E2EESendTextOptions,
|
|
23
|
-
E2EESendTextResult,
|
|
24
|
-
E2EEEncryptMediaOptions,
|
|
25
|
-
E2EEEncryptMediaResult,
|
|
26
|
-
E2EEDecryptMediaOptions,
|
|
27
|
-
E2EEDownloadOptions,
|
|
28
|
-
E2EEDownloadResult,
|
|
29
|
-
} from "./models/e2ee.ts";
|
|
30
|
-
export type {
|
|
31
|
-
MediaUploadConfig,
|
|
32
|
-
MediaUploadResult,
|
|
33
|
-
MmsTypeStr,
|
|
34
|
-
} from "./models/media.ts";
|