Plugin

Encryptcord

End-to-end encryption in Discord!

index.tsx
Download

Source

src/plugins/encryptcord/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { addChatBarButton, ChatBarButton } from "@api/ChatButtons";
8import {
9 ApplicationCommandInputType,
10 ApplicationCommandOptionType,
11 sendBotMessage,
12} from "@api/Commands";
13import * as DataStore from "@api/DataStore";
14import { addPreSendListener, removePreSendListener, SendListener } from "@api/MessageEvents";
15import { removeButton } from "@api/MessagePopover";
16import { Devs } from "@utils/constants";
17import { sleep } from "@utils/misc";
18import definePlugin from "@utils/types";
19import { findByPropsLazy } from "@webpack";
20import {
21 FluxDispatcher, MessageActions,
22 PrivateChannelsStore, RestAPI,
23 SnowflakeUtils,
24 useEffect, UserStore,
25 UserUtils, useState,
26} from "@webpack/common";
27import { Message } from "discord-types/general";
28
29import { decryptData, encryptData, formatPemKey, generateKeys } from "./rsa-utils";
30const MessageCreator = findByPropsLazy("createBotMessage");
31const CloudUtils = findByPropsLazy("CloudUpload");
32import { getCurrentChannel } from "@utils/discord";
33
34let enabled;
35let setEnabled;
36
37// Interface for Message Create
38interface IMessageCreate {
39 type: "MESSAGE_CREATE";
40 optimistic: boolean;
41 isPushNotification: boolean;
42 channelId: string;
43 message: Message;
44}
45
46// Chat Bar Icon Component
47const ChatBarIcon: ChatBarButton = ({ isMainChat }) => {
48 [enabled, setEnabled] = useState(false);
49 const [buttonDisabled, setButtonDisabled] = useState(false);
50
51 useEffect(() => {
52 const listener: SendListener = async (_, message) => {
53 if (enabled) {
54 const groupChannel = await DataStore.get("encryptcordChannelId");
55 if (getCurrentChannel().id !== groupChannel) {
56 sendBotMessage(getCurrentChannel().id, { content: `You must be in <#${groupChannel}> to send an encrypted message!\n> If you wish to send an unencrypted message, please click the button in the chatbar.` });
57 message.content = "";
58 return;
59 }
60 const trimmedMessage = message.content.trim();
61 await MessageActions.receiveMessage(groupChannel, await createMessage(trimmedMessage, UserStore.getCurrentUser().id, groupChannel, 0));
62 const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
63 const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => {
64 const groupMember = await UserUtils.getUser(memberId).catch(() => null);
65 if (!groupMember) return;
66 const encryptedMessage = await encryptData(encryptcordGroupMembers[memberId].key, trimmedMessage);
67 const encryptedMessageString = JSON.stringify(encryptedMessage);
68 await sendTempMessage(groupMember.id, encryptedMessageString, "message");
69 });
70
71 await Promise.all(dmPromises);
72 message.content = "";
73 }
74 };
75
76 addPreSendListener(listener);
77 return () => void removePreSendListener(listener);
78 }, [enabled]);
79
80 if (!isMainChat) return null;
81
82 return (
83 <ChatBarButton
84 tooltip={enabled ? "Send Unencrypted Messages" : "Send Encrypted Messages"}
85 onClick={async () => {
86 if (await DataStore.get("encryptcordGroup") === false || (await DataStore.get("encryptcordChannelId") !== getCurrentChannel().id)) {
87 setButtonDisabled(true);
88 await sendTempMessage(getCurrentChannel().id, "", `join\`\`\`\n${await DataStore.get("encryptcordPublicKey")}\`\`\``, false);
89 sendBotMessage(getCurrentChannel().id, { content: `*Checking for any groups in this channel...*\n> If none is found, a new one will be created <t:${Math.floor(Date.now() / 1000) + 5}:R>\n> [Tip] You can do \`/encryptcord leave\` to leave a group` });
90 await sleep(5000);
91 if (await DataStore.get("encryptcordGroup") === true && (await DataStore.get("encryptcordChannelId") !== getCurrentChannel().id)) {
92 sendBotMessage(getCurrentChannel().id, { content: "*Leaving current group...*" });
93 await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } });
94 } else if (await DataStore.get("encryptcordGroup") === true) {
95 setButtonDisabled(false);
96 return;
97 }
98 await startGroup("", { channel: { id: getCurrentChannel().id } });
99 }
100 setEnabled(!enabled);
101 setButtonDisabled(false);
102 }}
103 buttonProps={{
104 style: {
105 transition: "transform 0.3s ease-in-out",
106 transform: `rotate(${enabled ? 0 : 15}deg)`,
107 },
108 disabled: buttonDisabled
109 }}
110 >
111 <svg
112 width="24"
113 height="24"
114 viewBox="0 0 129 171"
115 >
116 {!enabled && <>
117 <mask id="encordBarIcon">
118 </mask>
119 <path
120 fill="currentColor"
121 d="M128.93 149.231V74.907a21.142 21.142 0 00-6.195-15.1 21.165 21.165 0 00-15.101-6.195h-1.085V40.918A40.604 40.604 0 0042.214 8.065 40.602 40.602 0 0026.28 32.318h15.972a25.164 25.164 0 0128.043-15.94 25.166 25.166 0 0120.691 24.745v12.694H22.184A21.276 21.276 0 00.89 75.111v74.325a21.27 21.27 0 0013.143 19.679 21.273 21.273 0 008.152 1.615h85.388a21.455 21.455 0 0015.083-6.357 21.453 21.453 0 006.213-15.142h.062zm-63.888-15.765a21.296 21.296 0 01-15.058-36.352 21.296 21.296 0 0136.354 15.057 21.151 21.151 0 01-21.296 21.295z"
122 />
123 </>
124 }
125 <path
126 mask="url(#encordBarIcon)"
127 fill="currentColor"
128 d="M129.497 149.264V75.001a21.27 21.27 0 00-21.295-21.294h-3.072V41.012a41.079 41.079 0 00-1.024-8.6A40.62 40.62 0 0070.729 1.087 5.673 5.673 0 0168.886.88h-.204c-.615 0-1.23-.205-1.844-.205h-4.095A5.672 5.672 0 0060.9.881h-.204a5.672 5.672 0 00-1.843.205A40.627 40.627 0 0025.27 32.413h.205a41.092 41.092 0 00-1.024 8.6v12.694h-3.133A21.153 21.153 0 00.023 75v74.325a21.415 21.415 0 0021.296 21.294h87.231a21.336 21.336 0 0020.886-21.294l.061-.062zm-64.91-15.97a21.317 21.317 0 01-22.069-24.804 21.316 21.316 0 0142.34 3.509 21.355 21.355 0 01-20.272 21.295zm25.185-79.649H39.604V40.951a24.283 24.283 0 016.963-17.2 25.351 25.351 0 0116.79-7.78h2.663a25.31 25.31 0 0123.752 25.184v12.49z"
129 />
130 </svg>
131 </ChatBarButton>
132 );
133};
134
135// Export Plugin
136export default definePlugin({
137 name: "Encryptcord",
138 description: "End-to-end encryption in Discord!",
139 authors: [Devs.FiveCord],
140 dependencies: ["CommandsAPI"],
141 patches: [
142 {
143 find: "executeMessageComponentInteraction:",
144 replacement: {
145 match: /await \i\.HTTP\.post\({url:\i\.Endpoints\.INTERACTIONS,body:\i,timeout:/,
146 replace: "await $self.joinGroup(C);$&"
147 }
148 }
149 ],
150 async joinGroup(interaction) {
151 const sender = await UserUtils.getUser(interaction.application_id).catch(() => null);
152 if (!sender || (sender.bot === true && sender.id !== "1")) return;
153 if (interaction.data.component_type !== 2) return;
154 switch (interaction.data.custom_id) {
155 case "removeFromSelf":
156 await handleLeaving(sender.id, await DataStore.get("encryptcordGroupMembers") ?? {}, interaction.channel_id);
157 await sendTempMessage(sender.id, "", "leaving");
158 FluxDispatcher.dispatch({
159 type: "MESSAGE_DELETE",
160 channelId: interaction.channel_id,
161 id: interaction.message_id,
162 mlDeleted: true
163 });
164 break;
165 case "createGroup":
166 await leave("", { channel: { id: interaction.channel_id } });
167 await startGroup("", { channel: { id: interaction.channel_id } });
168 break;
169 default:
170 return;
171 }
172 },
173 flux: {
174 async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) {
175 if (optimistic || type !== "MESSAGE_CREATE") return;
176 if (message.state === "SENDING") return;
177 if (message.author.id === UserStore.getCurrentUser().id) return;
178 if (!message.content) return;
179 const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
180 if (!Object.keys(encryptcordGroupMembers).some(key => key === message.author.id)) {
181 switch (message.content.toLowerCase().split("```")[0]) {
182 case "groupdata":
183 const response = await fetch(message.attachments[0].url);
184 const groupdata = await response.json();
185 await handleGroupData(groupdata);
186 break;
187 case "join":
188 if (encryptcordGroupMembers[UserStore.getCurrentUser().id].child) return;
189 if (!await DataStore.get("encryptcordGroup")) return;
190 const sender = await UserUtils.getUser(message.author.id).catch(() => null);
191 if (!sender) return;
192 const userKey = message.content.split("```")[1];
193 await handleJoin(sender.id, userKey, encryptcordGroupMembers);
194 break;
195 default:
196 break;
197 }
198 return;
199 }
200 const dmChannelId = await PrivateChannelsStore.getOrEnsurePrivateChannel(message.author.id);
201 if (channelId !== dmChannelId) return;
202 const sender = await UserUtils.getUser(message.author.id).catch(() => null);
203 if (!sender) return;
204 const groupChannel = await DataStore.get("encryptcordChannelId");
205 switch (message.content.toLowerCase()) {
206 case "leaving":
207 handleLeaving(sender.id, encryptcordGroupMembers, groupChannel);
208 break;
209 case "message":
210 const msgResponse = await fetch(message.attachments[0].url);
211 const messagedata = await msgResponse.json();
212 await handleMessage(messagedata, sender.id, groupChannel);
213 break;
214 case "groupdata":
215 const response = await fetch(message.attachments[0].url);
216 const groupdata = await response.json();
217 await handleGroupData(groupdata);
218 break;
219 default:
220 break;
221 }
222 },
223 },
224 commands: [
225 {
226 name: "encryptcord",
227 description: "End-to-end encryption in Discord!",
228 options: [
229 {
230 name: "leave",
231 description: "Leave current group",
232 options: [],
233 type: ApplicationCommandOptionType.SUB_COMMAND,
234 },
235 {
236 name: "data",
237 description: "View your keys and current group members",
238 options: [],
239 type: ApplicationCommandOptionType.SUB_COMMAND,
240 },
241 ],
242 inputType: ApplicationCommandInputType.BOT,
243 execute: (opts, ctx) => {
244 switch (opts[0].name) {
245 case "start":
246 startGroup(opts[0].options, ctx);
247 break;
248 case "leave":
249 leave(opts[0].options, ctx);
250 break;
251 case "data":
252 data(opts[0].options, ctx);
253 break;
254 }
255 },
256 },
257 ],
258 async start() {
259 addChatBarButton("Encryptcord", ChatBarIcon);
260 const pair = await generateKeys();
261 await DataStore.set("encryptcordPublicKey", pair.publicKey);
262 await DataStore.set("encryptcordPrivateKey", pair.privateKey);
263 if (await DataStore.get("encryptcordGroup") === true) {
264 await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } });
265 }
266 await DataStore.set("encryptcordGroup", false);
267 await DataStore.set("encryptcordChannelId", "");
268 await DataStore.set("encryptcordGroupMembers", {});
269 },
270 async stop() {
271 removeButton("Encryptcord");
272 if (await DataStore.get("encryptcordGroup") === true) {
273 await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } });
274 }
275 },
276});
277
278// Send Temporary Message
279async function sendTempMessage(recipientId: string, attachment: string, content: string, dm: boolean = true) {
280 if (recipientId === UserStore.getCurrentUser().id) return;
281 const channelId = dm ? await PrivateChannelsStore.getOrEnsurePrivateChannel(recipientId) : recipientId;
282 if (attachment && attachment !== "") {
283 const upload = await new CloudUtils.CloudUpload({
284 file: new File([new Blob([attachment])], "file.text", { type: "text/plain; charset=utf-8" }),
285 isClip: false,
286 isThumbnail: false,
287 platform: 1,
288 }, channelId, false, 0);
289 upload.on("complete", async () => {
290 const messageId = await RestAPI.post({
291 url: `/channels/${channelId}/messages`,
292 body: {
293 content,
294 attachments: [{
295 id: "0",
296 filename: upload.filename,
297 uploaded_filename: upload.uploadedFilename,
298 }],
299 nonce: SnowflakeUtils.fromTimestamp(Date.now()),
300 },
301 }).then(response => response.body.id);
302
303 await sleep(500);
304 MessageActions.deleteMessage(channelId, messageId);
305 });
306 await upload.upload();
307 return;
308 }
309
310 const messageId = await RestAPI.post({
311 url: `/channels/${channelId}/messages`,
312 body: {
313 content,
314 nonce: SnowflakeUtils.fromTimestamp(Date.now()),
315 },
316 }).then(response => response.body.id);
317
318 await sleep(500);
319 MessageActions.deleteMessage(channelId, messageId);
320}
321
322// Handle leaving group
323async function handleLeaving(senderId: string, encryptcordGroupMembers: object, groupChannel: string) {
324 const updatedMembers = Object.keys(encryptcordGroupMembers).reduce((result, memberId) => {
325 if (memberId !== senderId) {
326 result[memberId] = encryptcordGroupMembers[memberId];
327 if (result[memberId].child === senderId) {
328 result[memberId].child = encryptcordGroupMembers[senderId].child;
329 }
330 if (result[memberId].parent === senderId) {
331 result[memberId].parent = encryptcordGroupMembers[senderId].parent;
332 }
333 }
334 return result;
335 }, {});
336
337 await DataStore.set("encryptcordGroupMembers", updatedMembers);
338
339 await MessageActions.receiveMessage(groupChannel, await createMessage("", senderId, groupChannel, 2));
340}
341
342// Handle receiving message
343async function handleMessage(message, senderId: string, groupChannel: string) {
344 const decryptedMessage = await decryptData(await DataStore.get("encryptcordPrivateKey"), message);
345 await MessageActions.receiveMessage(groupChannel, await createMessage(decryptedMessage, senderId, groupChannel, 0));
346}
347
348// Handle receiving group data
349async function handleGroupData(groupData) {
350 await DataStore.set("encryptcordChannelId", groupData.channel);
351 await DataStore.set("encryptcordGroupMembers", groupData.members);
352 await DataStore.set("encryptcordGroup", true);
353 await MessageActions.receiveMessage(groupData.channel, await createMessage("", UserStore.getCurrentUser().id, groupData.channel, 7));
354 setEnabled(true);
355}
356
357// Handle joining group
358async function handleJoin(senderId: string, senderKey: string, encryptcordGroupMembers: object) {
359 encryptcordGroupMembers[senderId] = { key: senderKey, parent: UserStore.getCurrentUser().id, child: null };
360 encryptcordGroupMembers[UserStore.getCurrentUser().id].child = senderId;
361 await DataStore.set("encryptcordGroupMembers", encryptcordGroupMembers);
362 const groupChannel = await DataStore.get("encryptcordChannelId");
363 const newMember = await UserUtils.getUser(senderId).catch(() => null);
364 if (!newMember) return;
365
366 const membersData = {};
367 Object.entries(encryptcordGroupMembers)
368 .forEach(([memberId, value]) => {
369 membersData[memberId] = value;
370 });
371
372 const membersDataString = JSON.stringify({ members: membersData, channel: groupChannel });
373
374 const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => {
375 const groupMember = await UserUtils.getUser(memberId).catch(() => null);
376 if (!groupMember) return;
377 await sendTempMessage(groupMember.id, membersDataString, "groupdata");
378 });
379
380 await Promise.all(dmPromises);
381 await MessageActions.receiveMessage(groupChannel, {
382 ...await createMessage("", senderId, groupChannel, 7), components: [{
383 type: 1,
384 components: [{
385 type: 2,
386 style: 4,
387 label: "I don&#039;t want to talk to you!",
388 custom_id: "removeFromSelf"
389 },
390 {
391 type: 2,
392 style: 2,
393 label: "(Other users can still send/receive messages to/from them)",
394 disabled: true,
395 custom_id: "encryptcord"
396 }]
397 }]
398 });
399}
400
401// Create message for group
402async function createMessage(message: string, senderId: string, channelId: string, type: number) {
403 const messageStart = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] });
404 const sender = await UserUtils.getUser(senderId).catch(() => null);
405 if (!sender) return;
406 return { ...messageStart, content: message, author: sender, type, flags: 0 };
407}
408
409// Start E2EE Group
410async function startGroup(opts, ctx) {
411 const channelId = ctx.channel.id;
412 await DataStore.set("encryptcordChannelId", channelId);
413 await DataStore.set("encryptcordGroupMembers", {
414 [UserStore.getCurrentUser().id]: { key: await DataStore.get("encryptcordPublicKey"), parent: null, child: null }
415 });
416 await DataStore.set("encryptcordGroup", true);
417 sendBotMessage(channelId, { content: "Group created!\n> Other users can click the lock icon to join." });
418 await MessageActions.receiveMessage(channelId, await createMessage("", UserStore.getCurrentUser().id, channelId, 7));
419 setEnabled(true);
420}
421
422// Leave the Group;
423async function leave(opts, ctx) {
424 const channelId = ctx.channel.id;
425 if (!(await DataStore.get("encryptcordGroup"))) {
426 sendBotMessage(channelId, { content: "You&#039;re not in a group!" });
427 return;
428 }
429 const user = UserStore.getCurrentUser();
430 const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
431
432 const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => {
433 const groupMember = await UserUtils.getUser(memberId).catch(() => null);
434 if (!groupMember) return;
435 await sendTempMessage(groupMember.id, "", "leaving");
436 });
437
438 await Promise.all(dmPromises);
439 await DataStore.set("encryptcordGroup", false);
440 await DataStore.set("encryptcordChannelId", "");
441 await DataStore.set("encryptcordGroupMembers", {});
442 await MessageActions.receiveMessage(channelId, await createMessage("", user.id, channelId, 2));
443 setEnabled(false);
444}
445
446// View user data
447async function data(opts, ctx) {
448 const channelId = ctx.channel.id;
449 const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers");
450 const encryptcordPublicKey = await DataStore.get("encryptcordPublicKey");
451 const encryptcordPrivateKey = await DataStore.get("encryptcordPrivateKey");
452 const exportedPrivateKey = await crypto.subtle.exportKey("pkcs8", encryptcordPrivateKey);
453 const groupMembers = Object.keys(encryptcordGroupMembers);
454 sendBotMessage(channelId, {
455 content: `## Public key:\n\`\`\`${encryptcordPublicKey}\`\`\`\n## Private key:\n||\`\`\`${formatPemKey(exportedPrivateKey, "private")}\`\`\`||*(DO **NOT** SHARE THIS)*\n## Group members:\n\`\`\`json\n${JSON.stringify(groupMembers)}\`\`\``
456 });
457}
458