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,55 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "@jest/globals";
|
|
2
|
-
import { decryptMedia, encryptMedia, expandMediaKey, MmsType, sha256 } from "../../../src/e2ee/media/media-crypto.ts";
|
|
3
|
-
|
|
4
|
-
describe("media-crypto", () => {
|
|
5
|
-
it("encrypts and decrypts media with verifiable hashes", () => {
|
|
6
|
-
const plaintext = Buffer.from("hello encrypted media");
|
|
7
|
-
const encrypted = encryptMedia(plaintext, "image");
|
|
8
|
-
|
|
9
|
-
expect(encrypted.mediaKey.length).toBe(32);
|
|
10
|
-
expect(encrypted.fileLength).toBe(plaintext.length);
|
|
11
|
-
expect(encrypted.fileSHA256).toEqual(sha256(plaintext));
|
|
12
|
-
expect(encrypted.fileEncSHA256).toEqual(sha256(encrypted.dataToUpload));
|
|
13
|
-
|
|
14
|
-
const decrypted = decryptMedia({
|
|
15
|
-
data: encrypted.dataToUpload,
|
|
16
|
-
mediaKey: encrypted.mediaKey,
|
|
17
|
-
type: "image",
|
|
18
|
-
fileSHA256: encrypted.fileSHA256,
|
|
19
|
-
fileEncSHA256: encrypted.fileEncSHA256,
|
|
20
|
-
});
|
|
21
|
-
expect(decrypted).toEqual(plaintext);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("rejects tampered encrypted hashes before decryption", () => {
|
|
25
|
-
const encrypted = encryptMedia(Buffer.from("payload"), "video");
|
|
26
|
-
const badHash = Buffer.alloc(32, 1);
|
|
27
|
-
|
|
28
|
-
expect(() => decryptMedia({
|
|
29
|
-
data: encrypted.dataToUpload,
|
|
30
|
-
mediaKey: encrypted.mediaKey,
|
|
31
|
-
type: "video",
|
|
32
|
-
fileEncSHA256: badHash,
|
|
33
|
-
})).toThrow("Invalid media enc SHA256");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("rejects invalid HMAC and too-short media payloads", () => {
|
|
37
|
-
const encrypted = encryptMedia(Buffer.from("payload"), "audio");
|
|
38
|
-
const tampered = Buffer.from(encrypted.dataToUpload);
|
|
39
|
-
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff;
|
|
40
|
-
|
|
41
|
-
expect(() => decryptMedia({ data: tampered, mediaKey: encrypted.mediaKey, type: "audio" })).toThrow("Invalid media HMAC");
|
|
42
|
-
expect(() => decryptMedia({ data: Buffer.alloc(10), mediaKey: encrypted.mediaKey, type: "audio" })).toThrow("Media data too short");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("derives stable key sizes and maps MMS types", () => {
|
|
46
|
-
const keys = expandMediaKey(Buffer.alloc(32, 7), "document");
|
|
47
|
-
|
|
48
|
-
expect(keys.iv.length).toBe(16);
|
|
49
|
-
expect(keys.cipherKey.length).toBe(32);
|
|
50
|
-
expect(keys.macKey.length).toBe(32);
|
|
51
|
-
expect(keys.refKey.length).toBe(32);
|
|
52
|
-
expect(MmsType.audio).toBe("ptt");
|
|
53
|
-
expect(MmsType.sticker).toBe("image");
|
|
54
|
-
});
|
|
55
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { jest, describe, it, expect, afterEach } from "@jest/globals";
|
|
2
|
-
import { toMediaUploadToken, uploadMedia } from "../../../src/e2ee/media/media-upload.ts";
|
|
3
|
-
|
|
4
|
-
describe("media-upload", () => {
|
|
5
|
-
afterEach(() => {
|
|
6
|
-
jest.restoreAllMocks();
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("builds padded URL-safe media upload tokens", () => {
|
|
10
|
-
expect(toMediaUploadToken(Buffer.from([0xfb]))).toBe("-w==");
|
|
11
|
-
expect(toMediaUploadToken(Buffer.from([0xfb, 0xff]))).toBe("-_8=");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("rejects uploads without a media_conn auth token", async () => {
|
|
15
|
-
const fetchMock = jest.spyOn(globalThis, "fetch");
|
|
16
|
-
|
|
17
|
-
await expect(uploadMedia(
|
|
18
|
-
{ host: "rupload.facebook.com", auth: "" },
|
|
19
|
-
Buffer.from("encrypted"),
|
|
20
|
-
Buffer.alloc(32),
|
|
21
|
-
"image",
|
|
22
|
-
)).rejects.toThrow("Missing media upload auth token");
|
|
23
|
-
|
|
24
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("posts encrypted media using the server auth token and padded upload token", async () => {
|
|
28
|
-
const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
29
|
-
ok: true,
|
|
30
|
-
json: async () => ({
|
|
31
|
-
url: "https://cdn.example/image",
|
|
32
|
-
direct_path: "/direct/path",
|
|
33
|
-
handle: "handle-1",
|
|
34
|
-
object_id: "object-1",
|
|
35
|
-
}),
|
|
36
|
-
} as Response);
|
|
37
|
-
|
|
38
|
-
const result = await uploadMedia(
|
|
39
|
-
{ host: "rupload.facebook.com", auth: "server-auth" },
|
|
40
|
-
Buffer.from("encrypted"),
|
|
41
|
-
Buffer.from([0xfb]),
|
|
42
|
-
"image",
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
expect(result).toEqual({
|
|
46
|
-
url: "https://cdn.example/image",
|
|
47
|
-
directPath: "/direct/path",
|
|
48
|
-
handle: "handle-1",
|
|
49
|
-
objectId: "object-1",
|
|
50
|
-
});
|
|
51
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
52
|
-
|
|
53
|
-
const call = fetchMock.mock.calls[0];
|
|
54
|
-
expect(call).toBeDefined();
|
|
55
|
-
const [url, init] = call!;
|
|
56
|
-
expect(String(url)).toBe("https://rupload.facebook.com/wa-msgr/mms/image/-w==?auth=server-auth&token=-w%3D%3D");
|
|
57
|
-
expect(init?.method).toBe("POST");
|
|
58
|
-
expect((init?.headers as Record<string, string>)["Content-Length"]).toBe("9");
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { ProtoWriter } from "../../../src/e2ee/message/message-builder.ts";
|
|
2
|
-
|
|
3
|
-
describe("message-builder", () => {
|
|
4
|
-
describe("ProtoWriter", () => {
|
|
5
|
-
it("should encode varints correctly", () => {
|
|
6
|
-
const writer = new ProtoWriter();
|
|
7
|
-
writer.varint(1, 150); // Field 1, value 150
|
|
8
|
-
// 1 << 3 | 0 = 8 (header)
|
|
9
|
-
// 150 -> [0x96, 0x01] (varint)
|
|
10
|
-
expect(writer.build()).toEqual(Buffer.from([0x08, 0x96, 0x01]));
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("should encode bigint varints correctly", () => {
|
|
14
|
-
const writer = new ProtoWriter();
|
|
15
|
-
writer.uint64_varint(2, 1234567890123456789n);
|
|
16
|
-
const buf = writer.build();
|
|
17
|
-
expect(buf[0]).toBe((2 << 3) | 0);
|
|
18
|
-
expect(buf.length).toBeGreaterThan(5);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("should encode strings/bytes correctly", () => {
|
|
22
|
-
const writer = new ProtoWriter();
|
|
23
|
-
writer.string(3, "test");
|
|
24
|
-
// Header: (3 << 3) | 2 = 26 (0x1a)
|
|
25
|
-
// Len: 4
|
|
26
|
-
// Data: "test"
|
|
27
|
-
expect(writer.build()).toEqual(Buffer.from([0x1a, 0x04, 116, 101, 115, 116]));
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("should encode booleans correctly", () => {
|
|
31
|
-
const writer = new ProtoWriter();
|
|
32
|
-
writer.bool(4, true);
|
|
33
|
-
expect(writer.build()).toEqual(Buffer.from([(4 << 3) | 0, 0x01]));
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("should encode fixed64 correctly", () => {
|
|
37
|
-
const writer = new ProtoWriter();
|
|
38
|
-
writer.uint64(5, 0x0102030405060708n);
|
|
39
|
-
expect(writer.build()).toEqual(Buffer.from([(5 << 3) | 1, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]));
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
});
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "@jest/globals";
|
|
2
|
-
import {
|
|
3
|
-
decodeConsumerApplication,
|
|
4
|
-
decodeImageTransport,
|
|
5
|
-
decodeVideoTransport,
|
|
6
|
-
decodeAudioTransport,
|
|
7
|
-
decodeDocumentTransport,
|
|
8
|
-
decodeStickerTransport,
|
|
9
|
-
decodeMessageApplication,
|
|
10
|
-
decodeMessageTransport,
|
|
11
|
-
encodeClientPayload,
|
|
12
|
-
encodeMessageApplication,
|
|
13
|
-
encodeMessageTransport,
|
|
14
|
-
encodeEditMessage,
|
|
15
|
-
encodeReactionMessage,
|
|
16
|
-
encodeTextMessage,
|
|
17
|
-
encodeImageMessage,
|
|
18
|
-
encodeVideoMessage,
|
|
19
|
-
encodeAudioMessage,
|
|
20
|
-
encodeDocumentMessage,
|
|
21
|
-
encodeStickerMessage,
|
|
22
|
-
encodeRevokeMessage,
|
|
23
|
-
FB_CONSUMER_MESSAGE_VERSION,
|
|
24
|
-
FB_MESSAGE_APPLICATION_VERSION,
|
|
25
|
-
} from "../../../src/e2ee/message/message-builder.ts";
|
|
26
|
-
|
|
27
|
-
describe("message builders", () => {
|
|
28
|
-
it("encodes text consumer applications", () => {
|
|
29
|
-
const decoded = decodeConsumerApplication(encodeTextMessage("hello"));
|
|
30
|
-
|
|
31
|
-
expect(decoded.payload.content.messageText.text).toBe("hello");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("encodes reaction consumer applications", () => {
|
|
35
|
-
const decoded = decodeConsumerApplication(encodeReactionMessage("mid.1", "👍"));
|
|
36
|
-
|
|
37
|
-
expect(decoded.payload.content.reactionMessage.text).toBe("👍");
|
|
38
|
-
expect(decoded.payload.content.reactionMessage.key.ID).toBe("mid.1");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("encodes E2EE group reaction message keys with remote JID and participant", () => {
|
|
42
|
-
const decoded = decodeConsumerApplication(encodeReactionMessage("7456658723671758234", "👍", {
|
|
43
|
-
remoteJid: "1805602490133470@g.us",
|
|
44
|
-
fromMe: false,
|
|
45
|
-
participant: "100042415119261.0@msgr",
|
|
46
|
-
senderTimestampMs: 1777805979855,
|
|
47
|
-
}));
|
|
48
|
-
|
|
49
|
-
const reaction = decoded.payload.content.reactionMessage;
|
|
50
|
-
expect(reaction.text).toBe("👍");
|
|
51
|
-
expect(reaction.senderTimestampMS).toBe("1777805979855");
|
|
52
|
-
expect(reaction.key).toMatchObject({
|
|
53
|
-
remoteJID: "1805602490133470@g.us",
|
|
54
|
-
fromMe: false,
|
|
55
|
-
ID: "7456658723671758234",
|
|
56
|
-
participant: "100042415119261.0@msgr",
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("encodes edit consumer applications", () => {
|
|
61
|
-
const decoded = decodeConsumerApplication(encodeEditMessage("mid.1", "new text"));
|
|
62
|
-
|
|
63
|
-
expect(decoded.payload.content.editMessage.key.ID).toBe("mid.1");
|
|
64
|
-
expect(decoded.payload.content.editMessage.message.text).toBe("new text");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const media = {
|
|
70
|
-
mimeType: "image/png",
|
|
71
|
-
fileSHA256: Buffer.alloc(32, 1),
|
|
72
|
-
fileLength: 12345,
|
|
73
|
-
mediaKey: Buffer.alloc(32, 2),
|
|
74
|
-
fileEncSHA256: Buffer.alloc(32, 3),
|
|
75
|
-
directPath: "/mms/direct-path",
|
|
76
|
-
caption: "hello media",
|
|
77
|
-
width: 640,
|
|
78
|
-
height: 480,
|
|
79
|
-
seconds: 9,
|
|
80
|
-
ptt: true,
|
|
81
|
-
fileName: "file.pdf",
|
|
82
|
-
objectId: "object-123",
|
|
83
|
-
mediaKeyTimestamp: 1710000000,
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
it("encodes image messages using the image oneof and nested WAMediaTransport", () => {
|
|
87
|
-
const decoded = decodeConsumerApplication(encodeImageMessage(media));
|
|
88
|
-
const image = decoded.payload.content.imageMessage;
|
|
89
|
-
const transport = decodeImageTransport(image.image.payload);
|
|
90
|
-
const common = transport.integral.transport;
|
|
91
|
-
|
|
92
|
-
expect(image.caption.text).toBe("hello media");
|
|
93
|
-
expect(image.image.version).toBe(1);
|
|
94
|
-
expect(common.integral.fileSHA256).toEqual(media.fileSHA256);
|
|
95
|
-
expect(common.integral.mediaKey).toEqual(media.mediaKey);
|
|
96
|
-
expect(common.integral.fileEncSHA256).toEqual(media.fileEncSHA256);
|
|
97
|
-
expect(common.integral.directPath).toBe(media.directPath);
|
|
98
|
-
expect(common.integral.mediaKeyTimestamp).toBe(media.mediaKeyTimestamp);
|
|
99
|
-
expect(common.ancillary.mimetype).toBe("image/png");
|
|
100
|
-
expect(common.ancillary.fileLength).toBe(media.fileLength);
|
|
101
|
-
expect(common.ancillary.objectID).toBe("object-123");
|
|
102
|
-
expect(common.ancillary.thumbnail).toMatchObject({ thumbnailWidth: 640, thumbnailHeight: 480 });
|
|
103
|
-
expect(transport.ancillary).toMatchObject({ width: 640, height: 480 });
|
|
104
|
-
expect(decoded.payload.content.contactMessage).toBeUndefined();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("encodes video messages using the video oneof, not contactMessage", () => {
|
|
108
|
-
const decoded = decodeConsumerApplication(encodeVideoMessage({ ...media, mimeType: "video/mp4" }));
|
|
109
|
-
const video = decoded.payload.content.videoMessage;
|
|
110
|
-
const transport = decodeVideoTransport(video.video.payload);
|
|
111
|
-
const common = transport.integral.transport;
|
|
112
|
-
|
|
113
|
-
expect(video.caption.text).toBe("hello media");
|
|
114
|
-
expect(video.video.version).toBe(1);
|
|
115
|
-
expect(common.ancillary.mimetype).toBe("video/mp4");
|
|
116
|
-
expect(transport.ancillary).toMatchObject({ width: 640, height: 480, seconds: 9, gifPlayback: false });
|
|
117
|
-
expect(decoded.payload.content.contactMessage).toBeUndefined();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("encodes audio messages using the audio oneof with PTT metadata", () => {
|
|
121
|
-
const decoded = decodeConsumerApplication(encodeAudioMessage({ ...media, mimeType: "audio/ogg" }));
|
|
122
|
-
const audio = decoded.payload.content.audioMessage;
|
|
123
|
-
const transport = decodeAudioTransport(audio.audio.payload);
|
|
124
|
-
const common = transport.integral.transport;
|
|
125
|
-
|
|
126
|
-
expect(audio.PTT).toBe(true);
|
|
127
|
-
expect(audio.audio.version).toBe(1);
|
|
128
|
-
expect(common.ancillary.mimetype).toBe("audio/ogg");
|
|
129
|
-
expect(transport.ancillary.seconds).toBe(9);
|
|
130
|
-
expect(decoded.payload.content.locationMessage).toBeUndefined();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("encodes document messages using the document oneof with filename", () => {
|
|
134
|
-
const decoded = decodeConsumerApplication(encodeDocumentMessage({ ...media, mimeType: "application/pdf" }));
|
|
135
|
-
const document = decoded.payload.content.documentMessage;
|
|
136
|
-
const transport = decodeDocumentTransport(document.document.payload);
|
|
137
|
-
const common = transport.integral.transport;
|
|
138
|
-
|
|
139
|
-
expect(document.fileName).toBe("file.pdf");
|
|
140
|
-
expect(document.document.version).toBe(1);
|
|
141
|
-
expect(common.ancillary.mimetype).toBe("application/pdf");
|
|
142
|
-
expect(common.ancillary.objectID).toBe("object-123");
|
|
143
|
-
expect(decoded.payload.content.extendedTextMessage).toBeUndefined();
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("encodes sticker messages using the sticker oneof", () => {
|
|
147
|
-
const decoded = decodeConsumerApplication(encodeStickerMessage({ ...media, mimeType: "image/webp", caption: undefined }));
|
|
148
|
-
const sticker = decoded.payload.content.stickerMessage;
|
|
149
|
-
const transport = decodeStickerTransport(sticker.sticker.payload);
|
|
150
|
-
const common = transport.integral.transport;
|
|
151
|
-
|
|
152
|
-
expect(sticker.sticker.version).toBe(1);
|
|
153
|
-
expect(common.ancillary.mimetype).toBe("image/webp");
|
|
154
|
-
expect(transport.ancillary).toMatchObject({ width: 640, height: 480 });
|
|
155
|
-
expect(decoded.payload.content.statusTextMessage).toBeUndefined();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("encodes revoke messages as application data with a valid message key", () => {
|
|
159
|
-
const decoded = decodeConsumerApplication(encodeRevokeMessage("mid.remove", {
|
|
160
|
-
remoteJid: "180@g.us",
|
|
161
|
-
fromMe: true,
|
|
162
|
-
}));
|
|
163
|
-
|
|
164
|
-
expect(decoded.payload.applicationData.revoke.key.remoteJID).toBe("180@g.us");
|
|
165
|
-
expect(decoded.payload.applicationData.revoke.key.ID).toBe("mid.remove");
|
|
166
|
-
expect(decoded.payload.applicationData.revoke.key.fromMe).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("wraps consumer bytes in MessageApplication with franking metadata and quote", () => {
|
|
170
|
-
const consumer = encodeTextMessage("quoted");
|
|
171
|
-
const { messageApp, frankingKey, frankingTag } = encodeMessageApplication(consumer, {
|
|
172
|
-
id: "mid.quote",
|
|
173
|
-
senderJid: "100.1@msgr",
|
|
174
|
-
});
|
|
175
|
-
const decoded = decodeMessageApplication(messageApp);
|
|
176
|
-
|
|
177
|
-
expect(decoded.payload.subProtocol.consumerMessage.version).toBe(FB_CONSUMER_MESSAGE_VERSION);
|
|
178
|
-
expect(decoded.payload.subProtocol.consumerMessage.payload).toEqual(consumer);
|
|
179
|
-
expect(decoded.metadata.frankingKey).toEqual(frankingKey);
|
|
180
|
-
expect(frankingKey.length).toBe(32);
|
|
181
|
-
expect(frankingTag.length).toBe(32);
|
|
182
|
-
expect(decoded.metadata.quotedMessage.stanzaID).toBe("mid.quote");
|
|
183
|
-
expect(decoded.metadata.quotedMessage.remoteJID).toBe("100.1@msgr");
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("encodes MessageTransport with app payload, DSM, SKDM, and backup directive", () => {
|
|
187
|
-
const messageApp = Buffer.from("message-app");
|
|
188
|
-
const skdmBytes = Buffer.from("skdm");
|
|
189
|
-
const decoded = decodeMessageTransport(encodeMessageTransport({
|
|
190
|
-
messageApp,
|
|
191
|
-
padding: Buffer.from([1]),
|
|
192
|
-
dsm: { destinationJid: "200.0@msgr", phash: "hash" },
|
|
193
|
-
skdm: { groupId: "180@g.us", skdmBytes },
|
|
194
|
-
backupDirective: { messageId: "mid.1", actionType: "REMOVE" },
|
|
195
|
-
}));
|
|
196
|
-
|
|
197
|
-
expect(decoded.payload.applicationPayload.payload).toEqual(messageApp);
|
|
198
|
-
expect(decoded.payload.applicationPayload.version).toBe(FB_MESSAGE_APPLICATION_VERSION);
|
|
199
|
-
expect(decoded.protocol.integral.padding).toEqual(Buffer.from([1]));
|
|
200
|
-
expect(decoded.protocol.integral.DSM).toMatchObject({ destinationJID: "200.0@msgr", phash: "hash" });
|
|
201
|
-
expect(decoded.protocol.ancillary.skdm.groupID).toBe("180@g.us");
|
|
202
|
-
expect(decoded.protocol.ancillary.skdm.axolotlSenderKeyDistributionMessage).toEqual(skdmBytes);
|
|
203
|
-
expect(decoded.protocol.ancillary.backupDirective).toMatchObject({ messageID: "mid.1", actionType: "DELETE" });
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("encodes SKDM-only transports without application payload", () => {
|
|
207
|
-
const decoded = decodeMessageTransport(encodeMessageTransport({
|
|
208
|
-
padding: Buffer.from([2]),
|
|
209
|
-
skdm: { groupId: "180@g.us", skdmBytes: Buffer.from("skdm") },
|
|
210
|
-
}));
|
|
211
|
-
|
|
212
|
-
expect(decoded.payload).toBeUndefined();
|
|
213
|
-
expect(decoded.protocol.integral.padding).toEqual(Buffer.from([2]));
|
|
214
|
-
expect(decoded.protocol.ancillary.skdm.groupID).toBe("180@g.us");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("encodes client payload with username, device ID, CAT, user agent, and pull flag", () => {
|
|
218
|
-
const payload = encodeClientPayload({
|
|
219
|
-
username: 123456789n,
|
|
220
|
-
deviceId: 42,
|
|
221
|
-
fbCatBase64: "cat-token",
|
|
222
|
-
fbUserAgent: Buffer.from("ua"),
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
expect(payload).toContain(42);
|
|
226
|
-
expect(payload.includes(Buffer.from("cat-token"))).toBe(true);
|
|
227
|
-
expect(payload.includes(Buffer.from("ua"))).toBe(true);
|
|
228
|
-
expect([...payload.slice(-3)]).toEqual([0x88, 0x02, 0x01]); // field 33 bool header + true
|
|
229
|
-
});
|
|
230
|
-
});
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, createHash, hkdfSync } from "node:crypto";
|
|
2
|
-
import { doHandshake, generateX25519, getX25519PublicKey, x25519DH, WA_HEADER } from "../../../src/e2ee/transport/noise/noise-handshake.ts";
|
|
3
|
-
|
|
4
|
-
describe("noise-handshake", () => {
|
|
5
|
-
describe("X25519 helpers", () => {
|
|
6
|
-
it("should generate valid X25519 keys", () => {
|
|
7
|
-
const { priv, pub } = generateX25519();
|
|
8
|
-
expect(priv.length).toBe(32);
|
|
9
|
-
expect(pub.length).toBe(32);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("should derive public key from private key", () => {
|
|
13
|
-
const { priv, pub } = generateX25519();
|
|
14
|
-
const derivedPub = getX25519PublicKey(priv);
|
|
15
|
-
expect(derivedPub.toString("hex")).toBe(pub.toString("hex"));
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("should perform Diffie-Hellman exchange", () => {
|
|
19
|
-
const alice = generateX25519();
|
|
20
|
-
const bob = generateX25519();
|
|
21
|
-
|
|
22
|
-
const secret1 = x25519DH(alice.priv, bob.pub);
|
|
23
|
-
const secret2 = x25519DH(bob.priv, alice.pub);
|
|
24
|
-
|
|
25
|
-
expect(secret1.length).toBe(32);
|
|
26
|
-
expect(secret1.toString("hex")).toBe(secret2.toString("hex"));
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("Constants", () => {
|
|
31
|
-
it("should have the correct WA_HEADER", () => {
|
|
32
|
-
expect(WA_HEADER).toEqual(Buffer.from([87, 65, 6, 3]));
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const NOISE_START_PATTERN = Buffer.from("Noise_XX_25519_AESGCM_SHA256\0\0\0\0");
|
|
39
|
-
|
|
40
|
-
function sha256(data: Buffer): Buffer {
|
|
41
|
-
return Buffer.from(createHash("sha256").update(data).digest());
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
class TestNoiseState {
|
|
45
|
-
private h: Buffer;
|
|
46
|
-
private ck: Buffer;
|
|
47
|
-
private k: Buffer | null = null;
|
|
48
|
-
private n = 0;
|
|
49
|
-
|
|
50
|
-
constructor() {
|
|
51
|
-
this.h = NOISE_START_PATTERN.length === 32 ? Buffer.from(NOISE_START_PATTERN) : sha256(NOISE_START_PATTERN);
|
|
52
|
-
this.ck = this.h;
|
|
53
|
-
this.mixHash(WA_HEADER);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
mixHash(data: Buffer): void {
|
|
57
|
-
this.h = sha256(Buffer.concat([this.h, data]));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
mixKey(input: Buffer): void {
|
|
61
|
-
const expanded = Buffer.from(hkdfSync("sha256", input, this.ck, "", 64));
|
|
62
|
-
this.ck = expanded.subarray(0, 32);
|
|
63
|
-
this.k = expanded.subarray(32, 64);
|
|
64
|
-
this.n = 0;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
mixSharedSecretIntoKey(priv: Buffer, pub: Buffer): void {
|
|
68
|
-
this.mixKey(x25519DH(priv, pub));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
encrypt(plaintext: Buffer): Buffer {
|
|
72
|
-
if (!this.k) throw new Error("missing key");
|
|
73
|
-
const nonce = Buffer.alloc(12);
|
|
74
|
-
nonce.writeUInt32BE(this.n++, 8);
|
|
75
|
-
const cipher = createCipheriv("aes-256-gcm", this.k, nonce);
|
|
76
|
-
cipher.setAAD(this.h);
|
|
77
|
-
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
78
|
-
this.mixHash(encrypted);
|
|
79
|
-
return encrypted;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
decrypt(ciphertext: Buffer): Buffer {
|
|
83
|
-
if (!this.k) throw new Error("missing key");
|
|
84
|
-
const nonce = Buffer.alloc(12);
|
|
85
|
-
nonce.writeUInt32BE(this.n++, 8);
|
|
86
|
-
const body = ciphertext.subarray(0, -16);
|
|
87
|
-
const tag = ciphertext.subarray(-16);
|
|
88
|
-
const decipher = createDecipheriv("aes-256-gcm", this.k, nonce);
|
|
89
|
-
decipher.setAAD(this.h);
|
|
90
|
-
decipher.setAuthTag(tag);
|
|
91
|
-
const plaintext = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
92
|
-
this.mixHash(ciphertext);
|
|
93
|
-
return plaintext;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
finish(): { clientSendKey: Buffer; clientRecvKey: Buffer } {
|
|
97
|
-
const expanded = Buffer.from(hkdfSync("sha256", Buffer.alloc(0), this.ck, Buffer.alloc(0), 64));
|
|
98
|
-
return { clientSendKey: expanded.subarray(0, 32), clientRecvKey: expanded.subarray(32, 64) };
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function encVarint(n: number): Buffer {
|
|
103
|
-
const bytes: number[] = [];
|
|
104
|
-
let v = n >>> 0;
|
|
105
|
-
while (v > 127) {
|
|
106
|
-
bytes.push((v & 0x7f) | 0x80);
|
|
107
|
-
v >>>= 7;
|
|
108
|
-
}
|
|
109
|
-
bytes.push(v);
|
|
110
|
-
return Buffer.from(bytes);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function encLenDelim(field: number, data: Buffer): Buffer {
|
|
114
|
-
return Buffer.concat([encVarint((field << 3) | 2), encVarint(data.length), data]);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function decodeLenDelim(data: Buffer, targetField: number): Buffer {
|
|
118
|
-
let pos = 0;
|
|
119
|
-
while (pos < data.length) {
|
|
120
|
-
const tag = data[pos++]!;
|
|
121
|
-
const field = tag >> 3;
|
|
122
|
-
const wire = tag & 0x07;
|
|
123
|
-
if (wire !== 2) throw new Error(`unexpected wire ${wire}`);
|
|
124
|
-
let len = 0;
|
|
125
|
-
let shift = 0;
|
|
126
|
-
while (true) {
|
|
127
|
-
const b = data[pos++]!;
|
|
128
|
-
len |= (b & 0x7f) << shift;
|
|
129
|
-
if (!(b & 0x80)) break;
|
|
130
|
-
shift += 7;
|
|
131
|
-
}
|
|
132
|
-
const value = data.subarray(pos, pos + len);
|
|
133
|
-
pos += len;
|
|
134
|
-
if (field === targetField) return Buffer.from(value);
|
|
135
|
-
}
|
|
136
|
-
throw new Error(`field ${targetField} not found`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function prependLength(data: Buffer): Buffer {
|
|
140
|
-
const header = Buffer.alloc(3);
|
|
141
|
-
header.writeUIntBE(data.length, 0, 3);
|
|
142
|
-
return Buffer.concat([header, data]);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function decryptAppFrame(frame: Buffer, key: Buffer, counter: number): Buffer {
|
|
146
|
-
const len = frame.readUIntBE(0, 3);
|
|
147
|
-
const payload = frame.subarray(3, 3 + len);
|
|
148
|
-
const body = payload.subarray(0, -16);
|
|
149
|
-
const tag = payload.subarray(-16);
|
|
150
|
-
const nonce = Buffer.alloc(12);
|
|
151
|
-
nonce.writeBigUInt64BE(BigInt(counter), 4);
|
|
152
|
-
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
153
|
-
decipher.setAuthTag(tag);
|
|
154
|
-
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function encryptAppPayload(plaintext: Buffer, key: Buffer, counter: number): Buffer {
|
|
158
|
-
const nonce = Buffer.alloc(12);
|
|
159
|
-
nonce.writeBigUInt64BE(BigInt(counter), 4);
|
|
160
|
-
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
161
|
-
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
class FakeNoiseWebSocket {
|
|
165
|
-
public sent: Buffer[] = [];
|
|
166
|
-
public clientPayload: Buffer | null = null;
|
|
167
|
-
public keys: { clientSendKey: Buffer; clientRecvKey: Buffer } | null = null;
|
|
168
|
-
public closed = false;
|
|
169
|
-
private inbound: Buffer[] = [];
|
|
170
|
-
private readonly serverEphemeral = generateX25519();
|
|
171
|
-
private readonly serverStatic = generateX25519();
|
|
172
|
-
private readonly state = new TestNoiseState();
|
|
173
|
-
|
|
174
|
-
send(data: Buffer): void {
|
|
175
|
-
this.sent.push(Buffer.from(data));
|
|
176
|
-
if (this.sent.length === 2) this.processClientFinish(data);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async readRaw(len?: number): Promise<Buffer> {
|
|
180
|
-
if (this.inbound.length === 0) this.enqueueServerHello();
|
|
181
|
-
const chunk = this.inbound.shift();
|
|
182
|
-
if (!chunk) throw new Error("no inbound data");
|
|
183
|
-
if (len !== undefined) expect(chunk).toHaveLength(len);
|
|
184
|
-
return chunk;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
close(): void {
|
|
188
|
-
this.closed = true;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
enqueueServerAppFrame(plaintext: Buffer): void {
|
|
192
|
-
if (!this.keys) throw new Error("handshake keys not ready");
|
|
193
|
-
const encrypted = encryptAppPayload(plaintext, this.keys.clientRecvKey, 0);
|
|
194
|
-
const header = Buffer.alloc(3);
|
|
195
|
-
header.writeUIntBE(encrypted.length, 0, 3);
|
|
196
|
-
this.inbound.push(header, encrypted);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
private enqueueServerHello(): void {
|
|
200
|
-
const first = this.sent[0];
|
|
201
|
-
if (!first) throw new Error("client hello not sent");
|
|
202
|
-
expect(first.subarray(0, 4)).toEqual(WA_HEADER);
|
|
203
|
-
const clientHelloLen = first.readUIntBE(4, 3);
|
|
204
|
-
const clientHello = first.subarray(7, 7 + clientHelloLen);
|
|
205
|
-
const hello = decodeLenDelim(clientHello, 2);
|
|
206
|
-
const clientEphemeral = decodeLenDelim(hello, 1);
|
|
207
|
-
|
|
208
|
-
this.state.mixHash(clientEphemeral);
|
|
209
|
-
this.state.mixHash(this.serverEphemeral.pub);
|
|
210
|
-
this.state.mixSharedSecretIntoKey(this.serverEphemeral.priv, clientEphemeral);
|
|
211
|
-
const encryptedStatic = this.state.encrypt(this.serverStatic.pub);
|
|
212
|
-
this.state.mixSharedSecretIntoKey(this.serverStatic.priv, clientEphemeral);
|
|
213
|
-
const encryptedCert = this.state.encrypt(Buffer.from("test-cert"));
|
|
214
|
-
|
|
215
|
-
const serverHello = Buffer.concat([
|
|
216
|
-
encLenDelim(1, this.serverEphemeral.pub),
|
|
217
|
-
encLenDelim(2, encryptedStatic),
|
|
218
|
-
encLenDelim(3, encryptedCert),
|
|
219
|
-
]);
|
|
220
|
-
const frame = prependLength(encLenDelim(3, serverHello));
|
|
221
|
-
this.inbound.push(frame.subarray(0, 3), frame.subarray(3));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private processClientFinish(frame: Buffer): void {
|
|
225
|
-
const finishPayload = frame.subarray(3);
|
|
226
|
-
const finish = decodeLenDelim(finishPayload, 4);
|
|
227
|
-
const encryptedStatic = decodeLenDelim(finish, 1);
|
|
228
|
-
const encryptedPayload = decodeLenDelim(finish, 2);
|
|
229
|
-
const clientStatic = this.state.decrypt(encryptedStatic);
|
|
230
|
-
this.state.mixSharedSecretIntoKey(this.serverEphemeral.priv, clientStatic);
|
|
231
|
-
this.clientPayload = this.state.decrypt(encryptedPayload);
|
|
232
|
-
this.keys = this.state.finish();
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
describe("noise-handshake integration", () => {
|
|
237
|
-
it("performs a full Noise XX handshake and returns an encrypted frame socket", async () => {
|
|
238
|
-
const ws = new FakeNoiseWebSocket();
|
|
239
|
-
const noiseKey = generateX25519();
|
|
240
|
-
const clientPayload = Buffer.from("client-payload");
|
|
241
|
-
|
|
242
|
-
const result = await doHandshake(ws as any, noiseKey.priv, clientPayload);
|
|
243
|
-
|
|
244
|
-
expect(ws.sent).toHaveLength(2);
|
|
245
|
-
expect(ws.sent[0]!.subarray(0, 4)).toEqual(WA_HEADER);
|
|
246
|
-
expect(ws.sent[1]!.subarray(0, 4)).not.toEqual(WA_HEADER);
|
|
247
|
-
expect(ws.clientPayload).toEqual(clientPayload);
|
|
248
|
-
expect(ws.keys).not.toBeNull();
|
|
249
|
-
|
|
250
|
-
await result.socket.sendFrame(Buffer.from("ping"));
|
|
251
|
-
expect(ws.sent).toHaveLength(3);
|
|
252
|
-
expect(decryptAppFrame(ws.sent[2]!, ws.keys!.clientSendKey, 0)).toEqual(Buffer.from("ping"));
|
|
253
|
-
|
|
254
|
-
ws.enqueueServerAppFrame(Buffer.from("pong"));
|
|
255
|
-
await expect(result.socket.readFrame()).resolves.toEqual(Buffer.from("pong"));
|
|
256
|
-
|
|
257
|
-
result.socket.close();
|
|
258
|
-
expect(ws.closed).toBe(true);
|
|
259
|
-
});
|
|
260
|
-
});
|