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,90 @@
|
|
|
1
|
+
import type { MediaUploadConfig, MediaUploadResult, MmsTypeStr } from "../../models/media.ts";
|
|
2
|
+
export type { MediaUploadConfig, MediaUploadResult, MmsTypeStr };
|
|
3
|
+
|
|
4
|
+
export function toMediaUploadToken(fileEncSHA256: Buffer): string {
|
|
5
|
+
return fileEncSHA256.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Callback to refresh media upload config (e.g., re-query media_conn).
|
|
10
|
+
*/
|
|
11
|
+
export type RefreshUploadConfigFn = () => Promise<MediaUploadConfig>;
|
|
12
|
+
|
|
13
|
+
export interface UploadMediaOptions {
|
|
14
|
+
/** Optional callback to refresh auth config on 401. */
|
|
15
|
+
refreshConfig?: RefreshUploadConfigFn;
|
|
16
|
+
/** Max retry attempts after refresh (default 1). */
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Upload encrypted media bytes to Facebook's upload CDN.
|
|
22
|
+
* Supports retry on 401 by refreshing the upload config.
|
|
23
|
+
*/
|
|
24
|
+
export async function uploadMedia(
|
|
25
|
+
config: MediaUploadConfig,
|
|
26
|
+
data: Buffer,
|
|
27
|
+
fileEncSHA256: Buffer,
|
|
28
|
+
mmsType: MmsTypeStr,
|
|
29
|
+
options?: UploadMediaOptions,
|
|
30
|
+
): Promise<MediaUploadResult> {
|
|
31
|
+
if (!config.auth) {
|
|
32
|
+
throw new Error("Missing media upload auth token; query media_conn before uploading E2EE media");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const token = toMediaUploadToken(fileEncSHA256);
|
|
36
|
+
const maxRetries = options?.maxRetries ?? 1;
|
|
37
|
+
let currentConfig = config;
|
|
38
|
+
|
|
39
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
|
+
const uploadUrl = `https://${currentConfig.host}/wa-msgr/mms/${mmsType}/${token}?auth=${encodeURIComponent(currentConfig.auth)}&token=${encodeURIComponent(token)}`;
|
|
41
|
+
|
|
42
|
+
const resp = await fetch(uploadUrl, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/octet-stream",
|
|
46
|
+
"Content-Length": String(data.length),
|
|
47
|
+
"Origin": "https://www.facebook.com",
|
|
48
|
+
"Referer": "https://www.facebook.com/",
|
|
49
|
+
},
|
|
50
|
+
body: data,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (resp.ok) {
|
|
54
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
55
|
+
const stringField = (...keys: string[]): string => {
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
const value = json[key];
|
|
58
|
+
if (typeof value === "string") return value;
|
|
59
|
+
if (typeof value === "number") return String(value);
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
url: stringField("url"),
|
|
66
|
+
directPath: stringField("direct_path", "directPath"),
|
|
67
|
+
handle: stringField("handle"),
|
|
68
|
+
objectId: stringField("object_id", "objectID", "objectId"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// On 401, try to refresh config and retry
|
|
73
|
+
if (resp.status === 401 && attempt < maxRetries && options?.refreshConfig) {
|
|
74
|
+
const body = await resp.text().catch(() => "");
|
|
75
|
+
console.warn(`Media upload 401 (attempt ${attempt + 1}), refreshing config...`, body.slice(0, 200));
|
|
76
|
+
currentConfig = await options.refreshConfig();
|
|
77
|
+
if (!currentConfig.auth) {
|
|
78
|
+
throw new Error("Media upload refresh returned empty auth token");
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Non-401 or max retries exceeded
|
|
84
|
+
const body = await resp.text().catch(() => "");
|
|
85
|
+
throw new Error(`Media upload failed: HTTP ${resp.status} - ${body}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Should not reach here, but just in case
|
|
89
|
+
throw new Error("Media upload failed after retries");
|
|
90
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ProtoWriter } from "../proto/proto-writer.ts";
|
|
2
|
+
|
|
3
|
+
export interface ClientPayloadOptions {
|
|
4
|
+
username: bigint;
|
|
5
|
+
deviceId: number;
|
|
6
|
+
fbCat?: Buffer;
|
|
7
|
+
fbUserAgent?: Buffer;
|
|
8
|
+
fbAppID?: bigint;
|
|
9
|
+
fbDeviceID?: Buffer;
|
|
10
|
+
fbCatBase64?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ClientPayload encoding (Handshake Step 3)
|
|
14
|
+
|
|
15
|
+
export function encodeClientPayload(opts: ClientPayloadOptions): Buffer {
|
|
16
|
+
// AppVersion: 301.0.2
|
|
17
|
+
const appVersion = new ProtoWriter()
|
|
18
|
+
.varint(1, 301)
|
|
19
|
+
.varint(2, 0)
|
|
20
|
+
.varint(3, 2)
|
|
21
|
+
.build();
|
|
22
|
+
|
|
23
|
+
// UserAgent
|
|
24
|
+
const userAgent = new ProtoWriter()
|
|
25
|
+
.varint(1, 32) // Platform = BLUE_WEB (32)
|
|
26
|
+
.bytes(2, appVersion)
|
|
27
|
+
.string(3, "000") // mcc
|
|
28
|
+
.string(4, "000") // mnc
|
|
29
|
+
.string(5, "") // osVersion
|
|
30
|
+
.string(6, "Linux") // manufacturer (OSName)
|
|
31
|
+
.string(7, "Chrome") // device (BrowserName)
|
|
32
|
+
.string(8, "") // osBuildNumber
|
|
33
|
+
.varint(10, 3) // releaseChannel = DEBUG
|
|
34
|
+
.string(11, "en") // localeLanguage
|
|
35
|
+
.string(12, "en") // localeCountry
|
|
36
|
+
.build();
|
|
37
|
+
|
|
38
|
+
const UserAgentStr = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
|
|
39
|
+
|
|
40
|
+
// ClientPayload
|
|
41
|
+
let w = new ProtoWriter()
|
|
42
|
+
.uint64_varint(1, opts.username) // field 1
|
|
43
|
+
.bool(3, false) // field 3: passive
|
|
44
|
+
.bytes(5, userAgent) // field 5
|
|
45
|
+
.varint(12, 1) // field 12: connectType (WIFI_UNKNOWN)
|
|
46
|
+
.varint(13, 1) // field 13: connectReason (USER_ACTIVATED)
|
|
47
|
+
.varint(18, opts.deviceId) // field 18: device
|
|
48
|
+
.varint(20, 1) // field 20: product (MESSENGER)
|
|
49
|
+
.bytes(21, opts.fbCatBase64 ? Buffer.from(opts.fbCatBase64) : Buffer.alloc(0)) // field 21: fbCat (as base64 string bytes)
|
|
50
|
+
.bytes(22, opts.fbUserAgent ?? Buffer.from(UserAgentStr)) // field 22: fbUserAgent
|
|
51
|
+
.bool(33, true); // field 33: pull
|
|
52
|
+
|
|
53
|
+
return w.build();
|
|
54
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { MediaFields } from "../../../models/e2ee.ts";
|
|
2
|
+
import { ProtoWriter } from "../proto/proto-writer.ts";
|
|
3
|
+
|
|
4
|
+
export type { MediaFields };
|
|
5
|
+
|
|
6
|
+
// ConsumerApplication encoding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// MessageBuilder (Pattern 3: Builder Pattern with Type Safety)
|
|
10
|
+
|
|
11
|
+
type ContentType =
|
|
12
|
+
| { type: "text"; text: string }
|
|
13
|
+
| { type: "image"; media: MediaFields }
|
|
14
|
+
| { type: "video"; media: MediaFields }
|
|
15
|
+
| { type: "audio"; media: MediaFields }
|
|
16
|
+
| { type: "document"; media: MediaFields }
|
|
17
|
+
| { type: "sticker"; media: MediaFields }
|
|
18
|
+
| { type: "reaction"; emoji: string; targetId: string }
|
|
19
|
+
| { type: "edit"; text: string; targetId: string }
|
|
20
|
+
| { type: "revoke"; targetId: string; fromMe: boolean };
|
|
21
|
+
|
|
22
|
+
export class MessageBuilder {
|
|
23
|
+
private content?: ContentType;
|
|
24
|
+
private replyTo?: { id: string; senderJid: string };
|
|
25
|
+
|
|
26
|
+
setReply(id: string, senderJid: string): this {
|
|
27
|
+
this.replyTo = { id, senderJid };
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getReply() {
|
|
32
|
+
return this.replyTo;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setText(text: string): this {
|
|
36
|
+
this.content = { type: "text", text };
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setImage(media: MediaFields): this {
|
|
41
|
+
this.content = { type: "image", media };
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setVideo(media: MediaFields): this {
|
|
46
|
+
this.content = { type: "video", media };
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setAudio(media: MediaFields): this {
|
|
51
|
+
this.content = { type: "audio", media };
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setDocument(media: MediaFields): this {
|
|
56
|
+
this.content = { type: "document", media };
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setSticker(media: MediaFields): this {
|
|
61
|
+
this.content = { type: "sticker", media };
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setReaction(emoji: string, targetId: string): this {
|
|
66
|
+
this.content = { type: "reaction", emoji, targetId };
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setEdit(text: string, targetId: string): this {
|
|
71
|
+
this.content = { type: "edit", text, targetId };
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setRevoke(targetId: string, fromMe: boolean): this {
|
|
76
|
+
this.content = { type: "revoke", targetId, fromMe };
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
build(): Buffer {
|
|
81
|
+
if (!this.content) throw new Error("Message content not set");
|
|
82
|
+
|
|
83
|
+
switch (this.content.type) {
|
|
84
|
+
case "text":
|
|
85
|
+
return encodeTextMessage(this.content.text);
|
|
86
|
+
case "image":
|
|
87
|
+
return encodeImageMessage(this.content.media);
|
|
88
|
+
case "video":
|
|
89
|
+
return encodeVideoMessage(this.content.media);
|
|
90
|
+
case "audio":
|
|
91
|
+
return encodeAudioMessage(this.content.media);
|
|
92
|
+
case "document":
|
|
93
|
+
return encodeDocumentMessage(this.content.media);
|
|
94
|
+
case "sticker":
|
|
95
|
+
return encodeStickerMessage(this.content.media);
|
|
96
|
+
case "reaction":
|
|
97
|
+
return encodeReactionMessage(this.content.targetId, this.content.emoji);
|
|
98
|
+
case "edit":
|
|
99
|
+
return encodeEditMessage(this.content.targetId, this.content.text);
|
|
100
|
+
case "revoke":
|
|
101
|
+
return encodeRevokeMessage(this.content.targetId, this.content.fromMe);
|
|
102
|
+
default:
|
|
103
|
+
throw new Error("Unknown content type");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Encode a ConsumerApplication text message.
|
|
110
|
+
* Field 1 = Payload { field 1 = Content { field 1 = MessageText { field 1 = text } } }
|
|
111
|
+
*/
|
|
112
|
+
export function encodeTextMessage(text: string): Buffer {
|
|
113
|
+
const msgText = encodeMessageText(text);
|
|
114
|
+
const content = new ProtoWriter().bytes(1, msgText).build(); // oneof content field 1 = messageText
|
|
115
|
+
const payload = new ProtoWriter().bytes(1, content).build(); // oneof payload field 1 = Content
|
|
116
|
+
return new ProtoWriter().bytes(1, payload).build(); // ConsumerApplication { payload }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function encodeMessageText(text: string): Buffer {
|
|
120
|
+
return new ProtoWriter().string(1, text).build();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const MEDIA_TRANSPORT_VERSION = 1;
|
|
124
|
+
|
|
125
|
+
function encodeMediaSubProtocol(payload: Buffer): Buffer {
|
|
126
|
+
return new ProtoWriter()
|
|
127
|
+
.bytes(1, payload)
|
|
128
|
+
.varint(2, MEDIA_TRANSPORT_VERSION)
|
|
129
|
+
.build();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mediaKeyTimestampSeconds(m: MediaFields): bigint {
|
|
133
|
+
const seconds = m.mediaKeyTimestamp ?? Math.floor(Date.now() / 1000);
|
|
134
|
+
return BigInt(Math.max(0, Math.trunc(seconds)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function optionalDimension(value: number | undefined): number | undefined {
|
|
138
|
+
return value === undefined ? undefined : Math.max(0, Math.trunc(value));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function encodeDownloadableThumbnailMetadata(m: MediaFields): Buffer | undefined {
|
|
142
|
+
const width = optionalDimension(m.width);
|
|
143
|
+
const height = optionalDimension(m.height);
|
|
144
|
+
if (width === undefined && height === undefined) return undefined;
|
|
145
|
+
|
|
146
|
+
let thumbnail = new ProtoWriter();
|
|
147
|
+
if (width !== undefined) thumbnail = thumbnail.varint(3, width);
|
|
148
|
+
if (height !== undefined) thumbnail = thumbnail.varint(4, height);
|
|
149
|
+
return thumbnail.build();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encode WAMediaTransport.WAMediaTransport.
|
|
154
|
+
*
|
|
155
|
+
* Modern Messenger media messages no longer carry a flat media payload directly in
|
|
156
|
+
* ImageMessage/VideoMessage/etc. They carry a WACommon.SubProtocol whose payload
|
|
157
|
+
* is a concrete media transport (ImageTransport, VideoTransport, ...). Each media
|
|
158
|
+
* transport then nests this common WAMediaTransport with the encrypted-file
|
|
159
|
+
* checksums, key, direct path, mimetype, file length and upload object ID.
|
|
160
|
+
*/
|
|
161
|
+
function encodeCommonMediaTransport(m: MediaFields, includeThumbnailMetadata: boolean): Buffer {
|
|
162
|
+
const integral = new ProtoWriter()
|
|
163
|
+
.bytes(1, m.fileSHA256)
|
|
164
|
+
.bytes(2, m.mediaKey)
|
|
165
|
+
.bytes(3, m.fileEncSHA256)
|
|
166
|
+
.string(4, m.directPath)
|
|
167
|
+
.uint64_varint(5, mediaKeyTimestampSeconds(m))
|
|
168
|
+
.build();
|
|
169
|
+
|
|
170
|
+
let ancillary = new ProtoWriter()
|
|
171
|
+
.uint64_varint(1, BigInt(Math.max(0, Math.trunc(m.fileLength))))
|
|
172
|
+
.string(2, m.mimeType);
|
|
173
|
+
|
|
174
|
+
const thumbnail = includeThumbnailMetadata ? encodeDownloadableThumbnailMetadata(m) : undefined;
|
|
175
|
+
if (thumbnail) ancillary = ancillary.bytes(3, thumbnail);
|
|
176
|
+
if (m.objectId) ancillary = ancillary.string(4, m.objectId);
|
|
177
|
+
|
|
178
|
+
return new ProtoWriter()
|
|
179
|
+
.bytes(1, integral)
|
|
180
|
+
.bytes(2, ancillary.build())
|
|
181
|
+
.build();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function encodeMediaTransportIntegral(commonTransport: Buffer): Buffer {
|
|
185
|
+
return new ProtoWriter().bytes(1, commonTransport).build();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function encodeImageTransportPayload(m: MediaFields): Buffer {
|
|
189
|
+
const width = optionalDimension(m.width);
|
|
190
|
+
const height = optionalDimension(m.height);
|
|
191
|
+
const transport = encodeCommonMediaTransport(m, true);
|
|
192
|
+
|
|
193
|
+
let ancillary = new ProtoWriter();
|
|
194
|
+
if (height !== undefined) ancillary = ancillary.varint(1, height);
|
|
195
|
+
if (width !== undefined) ancillary = ancillary.varint(2, width);
|
|
196
|
+
|
|
197
|
+
return new ProtoWriter()
|
|
198
|
+
.bytes(1, encodeMediaTransportIntegral(transport))
|
|
199
|
+
.bytes(2, ancillary.build())
|
|
200
|
+
.build();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function encodeVideoTransportPayload(m: MediaFields): Buffer {
|
|
204
|
+
const width = optionalDimension(m.width);
|
|
205
|
+
const height = optionalDimension(m.height);
|
|
206
|
+
const seconds = optionalDimension(m.seconds);
|
|
207
|
+
const transport = encodeCommonMediaTransport(m, true);
|
|
208
|
+
|
|
209
|
+
let ancillary = new ProtoWriter();
|
|
210
|
+
if (seconds !== undefined) ancillary = ancillary.varint(1, seconds);
|
|
211
|
+
// Native Messenger sends gifPlayback explicitly as false for normal videos.
|
|
212
|
+
ancillary = ancillary.bool(3, false);
|
|
213
|
+
if (height !== undefined) ancillary = ancillary.varint(4, height);
|
|
214
|
+
if (width !== undefined) ancillary = ancillary.varint(5, width);
|
|
215
|
+
|
|
216
|
+
return new ProtoWriter()
|
|
217
|
+
.bytes(1, encodeMediaTransportIntegral(transport))
|
|
218
|
+
.bytes(2, ancillary.build())
|
|
219
|
+
.build();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function encodeAudioTransportPayload(m: MediaFields): Buffer {
|
|
223
|
+
const seconds = optionalDimension(m.seconds);
|
|
224
|
+
const transport = encodeCommonMediaTransport(m, false);
|
|
225
|
+
|
|
226
|
+
let ancillary = new ProtoWriter();
|
|
227
|
+
if (seconds !== undefined) ancillary = ancillary.varint(1, seconds);
|
|
228
|
+
|
|
229
|
+
return new ProtoWriter()
|
|
230
|
+
.bytes(1, encodeMediaTransportIntegral(transport))
|
|
231
|
+
.bytes(2, ancillary.build())
|
|
232
|
+
.build();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function encodeDocumentTransportPayload(m: MediaFields): Buffer {
|
|
236
|
+
const transport = encodeCommonMediaTransport(m, false);
|
|
237
|
+
return new ProtoWriter()
|
|
238
|
+
.bytes(1, encodeMediaTransportIntegral(transport))
|
|
239
|
+
.bytes(2, Buffer.alloc(0))
|
|
240
|
+
.build();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function encodeStickerTransportPayload(m: MediaFields): Buffer {
|
|
244
|
+
const width = optionalDimension(m.width);
|
|
245
|
+
const height = optionalDimension(m.height);
|
|
246
|
+
const transport = encodeCommonMediaTransport(m, true);
|
|
247
|
+
|
|
248
|
+
const integral = new ProtoWriter()
|
|
249
|
+
.bytes(1, transport)
|
|
250
|
+
.build();
|
|
251
|
+
|
|
252
|
+
let ancillary = new ProtoWriter();
|
|
253
|
+
if (height !== undefined) ancillary = ancillary.varint(2, height);
|
|
254
|
+
if (width !== undefined) ancillary = ancillary.varint(3, width);
|
|
255
|
+
|
|
256
|
+
return new ProtoWriter()
|
|
257
|
+
.bytes(1, integral)
|
|
258
|
+
.bytes(2, ancillary.build())
|
|
259
|
+
.build();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Encode a ConsumerApplication image message. */
|
|
263
|
+
export function encodeImageMessage(m: MediaFields): Buffer {
|
|
264
|
+
let w = new ProtoWriter().bytes(1, encodeMediaSubProtocol(encodeImageTransportPayload(m)));
|
|
265
|
+
if (m.caption) w = w.bytes(2, encodeMessageText(m.caption));
|
|
266
|
+
const content = new ProtoWriter().bytes(2, w.build()).build();
|
|
267
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
268
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Encode a ConsumerApplication video message. */
|
|
272
|
+
export function encodeVideoMessage(m: MediaFields): Buffer {
|
|
273
|
+
let w = new ProtoWriter().bytes(1, encodeMediaSubProtocol(encodeVideoTransportPayload(m)));
|
|
274
|
+
if (m.caption) w = w.bytes(2, encodeMessageText(m.caption));
|
|
275
|
+
const content = new ProtoWriter().bytes(9, w.build()).build();
|
|
276
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
277
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Encode a ConsumerApplication audio/voice message. */
|
|
281
|
+
export function encodeAudioMessage(m: MediaFields): Buffer {
|
|
282
|
+
let w = new ProtoWriter().bytes(1, encodeMediaSubProtocol(encodeAudioTransportPayload(m)));
|
|
283
|
+
if (m.ptt) w = w.bool(2, true);
|
|
284
|
+
const content = new ProtoWriter().bytes(8, w.build()).build();
|
|
285
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
286
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Encode a ConsumerApplication document message. */
|
|
290
|
+
export function encodeDocumentMessage(m: MediaFields): Buffer {
|
|
291
|
+
let w = new ProtoWriter().bytes(1, encodeMediaSubProtocol(encodeDocumentTransportPayload(m)));
|
|
292
|
+
if (m.fileName) w = w.string(2, m.fileName);
|
|
293
|
+
const content = new ProtoWriter().bytes(7, w.build()).build();
|
|
294
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
295
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Encode a ConsumerApplication sticker message. */
|
|
299
|
+
export function encodeStickerMessage(m: MediaFields): Buffer {
|
|
300
|
+
const stickerMsg = new ProtoWriter()
|
|
301
|
+
.bytes(1, encodeMediaSubProtocol(encodeStickerTransportPayload(m)))
|
|
302
|
+
.build();
|
|
303
|
+
const content = new ProtoWriter().bytes(12, stickerMsg).build();
|
|
304
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
305
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface MessageKeyOptions {
|
|
309
|
+
remoteJid?: string;
|
|
310
|
+
fromMe?: boolean;
|
|
311
|
+
participant?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface ReactionMessageKeyOptions extends MessageKeyOptions {
|
|
315
|
+
senderTimestampMs?: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function encodeMessageKey(messageId: string, keyOpts: MessageKeyOptions = {}): Buffer {
|
|
319
|
+
let key = new ProtoWriter();
|
|
320
|
+
if (keyOpts.remoteJid) key = key.string(1, keyOpts.remoteJid);
|
|
321
|
+
if (typeof keyOpts.fromMe === "boolean") key = key.bool(2, keyOpts.fromMe);
|
|
322
|
+
key = key.string(3, messageId);
|
|
323
|
+
if (keyOpts.participant) key = key.string(4, keyOpts.participant);
|
|
324
|
+
return key.build();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Encode a reaction message. */
|
|
328
|
+
export function encodeReactionMessage(targetMessageId: string, emoji: string, keyOpts: ReactionMessageKeyOptions = {}): Buffer {
|
|
329
|
+
const reaction = new ProtoWriter()
|
|
330
|
+
.bytes(1, encodeMessageKey(targetMessageId, keyOpts))
|
|
331
|
+
.string(2, emoji)
|
|
332
|
+
.uint64_varint(4, BigInt(keyOpts.senderTimestampMs ?? Date.now()))
|
|
333
|
+
.build();
|
|
334
|
+
const content = new ProtoWriter().bytes(16, reaction).build();
|
|
335
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
336
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Encode a message edit. */
|
|
340
|
+
export function encodeEditMessage(targetMessageId: string, newText: string): Buffer {
|
|
341
|
+
const key = new ProtoWriter().string(3, targetMessageId).build();
|
|
342
|
+
const msgText = new ProtoWriter().string(1, newText).build();
|
|
343
|
+
const edit = new ProtoWriter()
|
|
344
|
+
.bytes(1, key)
|
|
345
|
+
.bytes(2, msgText)
|
|
346
|
+
.uint64_varint(3, BigInt(Date.now()))
|
|
347
|
+
.build();
|
|
348
|
+
const content = new ProtoWriter().bytes(19, edit).build();
|
|
349
|
+
const payload = new ProtoWriter().bytes(1, content).build();
|
|
350
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Encode a revoke (unsend) message. */
|
|
354
|
+
export function encodeRevokeMessage(messageId: string, keyOptsOrFromMe: MessageKeyOptions | boolean = true): Buffer {
|
|
355
|
+
const keyOpts = typeof keyOptsOrFromMe === "boolean"
|
|
356
|
+
? { fromMe: keyOptsOrFromMe }
|
|
357
|
+
: keyOptsOrFromMe;
|
|
358
|
+
const revoke = new ProtoWriter().bytes(1, encodeMessageKey(messageId, keyOpts)).build();
|
|
359
|
+
const applicationData = new ProtoWriter().bytes(1, revoke).build();
|
|
360
|
+
const payload = new ProtoWriter().bytes(2, applicationData).build();
|
|
361
|
+
return new ProtoWriter().bytes(1, payload).build();
|
|
362
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { FB_CONSUMER_MESSAGE_VERSION } from "../constants.ts";
|
|
3
|
+
import { ProtoWriter } from "../proto/proto-writer.ts";
|
|
4
|
+
|
|
5
|
+
// MessageApplication encoding
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrap a ConsumerApplication payload into a MessageApplication.
|
|
9
|
+
* Returns (messageApp bytes, frankingKey, frankingTag).
|
|
10
|
+
*/
|
|
11
|
+
export function encodeMessageApplication(
|
|
12
|
+
consumerAppBytes: Buffer,
|
|
13
|
+
replyTo?: { id: string; senderJid: string }
|
|
14
|
+
): {
|
|
15
|
+
messageApp: Buffer;
|
|
16
|
+
frankingKey: Buffer;
|
|
17
|
+
frankingTag: Buffer;
|
|
18
|
+
} {
|
|
19
|
+
const frankingKey = randomBytes(32);
|
|
20
|
+
|
|
21
|
+
// SubProtocol { payload=consumerAppBytes, version=FB_CONSUMER_MESSAGE_VERSION }
|
|
22
|
+
const subProtocol = new ProtoWriter()
|
|
23
|
+
.bytes(1, consumerAppBytes)
|
|
24
|
+
.varint(2, FB_CONSUMER_MESSAGE_VERSION)
|
|
25
|
+
.build();
|
|
26
|
+
|
|
27
|
+
// MessageApplication.SubProtocolPayload {
|
|
28
|
+
// futureProof = PLACEHOLDER (field 1)
|
|
29
|
+
// consumerMessage = WACommon.SubProtocol (field 2)
|
|
30
|
+
// }
|
|
31
|
+
const payloadSubProto = new ProtoWriter()
|
|
32
|
+
.varint(1, 0)
|
|
33
|
+
.bytes(2, subProtocol)
|
|
34
|
+
.build();
|
|
35
|
+
|
|
36
|
+
// MessageApplication.Payload { subProtocol = payloadSubProto } (field 4)
|
|
37
|
+
const appPayload = new ProtoWriter().bytes(4, payloadSubProto).build();
|
|
38
|
+
|
|
39
|
+
// MessageApplication_Metadata { frankingKey=8, frankingVersion=9, quotedMessage=10 }
|
|
40
|
+
let metadataWriter = new ProtoWriter()
|
|
41
|
+
.bytes(8, frankingKey)
|
|
42
|
+
.varint(9, 0); // frankingVersion
|
|
43
|
+
|
|
44
|
+
if (replyTo) {
|
|
45
|
+
const quoted = new ProtoWriter()
|
|
46
|
+
.string(1, replyTo.id)
|
|
47
|
+
.string(2, replyTo.senderJid)
|
|
48
|
+
.build();
|
|
49
|
+
metadataWriter = metadataWriter.bytes(10, quoted);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const metadata = metadataWriter.build();
|
|
53
|
+
|
|
54
|
+
// MessageApplication { payload, metadata }
|
|
55
|
+
const messageApp = new ProtoWriter()
|
|
56
|
+
.bytes(1, appPayload)
|
|
57
|
+
.bytes(2, metadata)
|
|
58
|
+
.build();
|
|
59
|
+
|
|
60
|
+
// frankingTag = HMAC-SHA256(frankingKey, messageApp)
|
|
61
|
+
const frankingTag = createHmac("sha256", frankingKey).update(messageApp).digest();
|
|
62
|
+
|
|
63
|
+
return { messageApp, frankingKey, frankingTag };
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { MessageTransportOptions } from "../../../models/e2ee.ts";
|
|
3
|
+
import { FB_MESSAGE_APPLICATION_VERSION } from "../constants.ts";
|
|
4
|
+
import { ProtoWriter } from "../proto/proto-writer.ts";
|
|
5
|
+
|
|
6
|
+
export type { MessageTransportOptions };
|
|
7
|
+
|
|
8
|
+
// MessageTransport encoding (plaintext before Signal encryption)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Encode the MessageTransport protobuf that will be fed into Signal cipher.
|
|
13
|
+
*/
|
|
14
|
+
export function encodeMessageTransport(opts: MessageTransportOptions): Buffer {
|
|
15
|
+
const padding = opts.padding ?? generatePadding();
|
|
16
|
+
|
|
17
|
+
let payload: Buffer | undefined;
|
|
18
|
+
if (opts.messageApp) {
|
|
19
|
+
// Payload.ApplicationPayload (SubProtocol)
|
|
20
|
+
const appPayload = new ProtoWriter()
|
|
21
|
+
.bytes(1, opts.messageApp)
|
|
22
|
+
.varint(2, FB_MESSAGE_APPLICATION_VERSION)
|
|
23
|
+
.build();
|
|
24
|
+
|
|
25
|
+
// Payload
|
|
26
|
+
payload = new ProtoWriter()
|
|
27
|
+
.bytes(1, appPayload)
|
|
28
|
+
.varint(3, 0) // futureProof PLACEHOLDER
|
|
29
|
+
.build();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Protocol.Integral
|
|
33
|
+
let integral = new ProtoWriter().bytes(1, padding);
|
|
34
|
+
if (opts.dsm) {
|
|
35
|
+
const dsmMsg = new ProtoWriter()
|
|
36
|
+
.string(1, opts.dsm.destinationJid)
|
|
37
|
+
.string(2, opts.dsm.phash)
|
|
38
|
+
.build();
|
|
39
|
+
integral = integral.bytes(2, dsmMsg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Protocol.Ancillary
|
|
43
|
+
let ancillary = new ProtoWriter();
|
|
44
|
+
if (opts.skdm) {
|
|
45
|
+
const skdmMsg = new ProtoWriter()
|
|
46
|
+
.string(1, opts.skdm.groupId)
|
|
47
|
+
.bytes(2, opts.skdm.skdmBytes)
|
|
48
|
+
.build();
|
|
49
|
+
ancillary = ancillary.bytes(2, skdmMsg);
|
|
50
|
+
}
|
|
51
|
+
if (opts.backupDirective) {
|
|
52
|
+
const actionType = opts.backupDirective.actionType === "REMOVE" ? 2 : 1;
|
|
53
|
+
const backupDirectiveMsg = new ProtoWriter()
|
|
54
|
+
.string(1, opts.backupDirective.messageId)
|
|
55
|
+
.varint(2, actionType)
|
|
56
|
+
.build();
|
|
57
|
+
ancillary = ancillary.bytes(5, backupDirectiveMsg);
|
|
58
|
+
}
|
|
59
|
+
// Protocol
|
|
60
|
+
const protocol = new ProtoWriter()
|
|
61
|
+
.bytes(1, integral.build())
|
|
62
|
+
.bytes(2, ancillary.build())
|
|
63
|
+
.build();
|
|
64
|
+
|
|
65
|
+
// MessageTransport
|
|
66
|
+
const transport = new ProtoWriter();
|
|
67
|
+
if (payload) transport.bytes(1, payload);
|
|
68
|
+
return transport
|
|
69
|
+
.bytes(2, protocol)
|
|
70
|
+
.build();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Helpers
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate a random padding buffer.
|
|
77
|
+
* Uses random length 1–255 and stores the padding length in the final byte.
|
|
78
|
+
*/
|
|
79
|
+
function generatePadding(): Buffer {
|
|
80
|
+
const len = (randomBytes(1)[0]! & 0xff) || 1;
|
|
81
|
+
const pad = randomBytes(len);
|
|
82
|
+
pad[len - 1] = len; // last byte = length (PKCS7-style)
|
|
83
|
+
return pad;
|
|
84
|
+
}
|