From a68acb4c5e149d9f49f2723f7c5c1b7d9b2aea3c Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Mon, 24 Jun 2024 15:02:17 -0400 Subject: [PATCH] Encryptcord --- src/equicordplugins/encryptcord/index.tsx | 455 ++++++++++++++++++ src/equicordplugins/encryptcord/rsa-utils.tsx | 121 +++++ 2 files changed, 576 insertions(+) create mode 100644 src/equicordplugins/encryptcord/index.tsx create mode 100644 src/equicordplugins/encryptcord/rsa-utils.tsx diff --git a/src/equicordplugins/encryptcord/index.tsx b/src/equicordplugins/encryptcord/index.tsx new file mode 100644 index 00000000..37fdb64d --- /dev/null +++ b/src/equicordplugins/encryptcord/index.tsx @@ -0,0 +1,455 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { addChatBarButton, ChatBarButton } from "@api/ChatButtons"; +import { + ApplicationCommandInputType, + ApplicationCommandOptionType, + sendBotMessage, +} from "@api/Commands"; +import * as DataStore from "@api/DataStore"; +import { addPreSendListener, removePreSendListener, SendListener } from "@api/MessageEvents"; +import { removeButton } from "@api/MessagePopover"; +import { Devs } from "@utils/constants"; +import { sleep } from "@utils/misc"; +import definePlugin from "@utils/types"; +import { + FluxDispatcher, MessageActions, + PrivateChannelsStore, RestAPI, + SnowflakeUtils, + useEffect, UserStore, + UserUtils, useState, +} from "@webpack/common"; +import { Message } from "discord-types/general"; +const CloudUpload = findLazy(m => m.prototype?.trackUploadFinished); + +import { getCurrentChannel } from "@utils/discord"; +import { findLazy } from "@webpack"; + +import { decryptData, encryptData, formatPemKey, generateKeys } from "./rsa-utils"; + +let enabled; +let setEnabled; + +interface IMessageCreate { + type: "MESSAGE_CREATE"; + optimistic: boolean; + isPushNotification: boolean; + channelId: string; + message: Message; +} + +const ChatBarIcon: ChatBarButton = ({ isMainChat }) => { + [enabled, setEnabled] = useState(false); + const [buttonDisabled, setButtonDisabled] = useState(false); + + useEffect(() => { + const listener: SendListener = async (_, message) => { + if (enabled) { + const groupChannel = await DataStore.get("encryptcordChannelId"); + if (getCurrentChannel().id !== groupChannel) { + 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.` }); + message.content = ""; + return; + } + const trimmedMessage = message.content.trim(); + await MessageActions.receiveMessage(groupChannel, await createMessage(trimmedMessage, UserStore.getCurrentUser().id, groupChannel, 0)); + const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers"); + const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => { + const groupMember = await UserUtils.getUser(memberId).catch(() => null); + if (!groupMember) return; + const encryptedMessage = await encryptData(encryptcordGroupMembers[memberId].key, trimmedMessage); + const encryptedMessageString = JSON.stringify(encryptedMessage); + await sendTempMessage(groupMember.id, encryptedMessageString, "message"); + }); + + await Promise.all(dmPromises); + message.content = ""; + } + }; + + addPreSendListener(listener); + return () => void removePreSendListener(listener); + }, [enabled]); + + if (!isMainChat) return null; + + return ( + { + if (await DataStore.get("encryptcordGroup") === false || (await DataStore.get("encryptcordChannelId") !== getCurrentChannel().id)) { + setButtonDisabled(true); + await sendTempMessage(getCurrentChannel().id, "", `join\`\`\`\n${await DataStore.get("encryptcordPublicKey")}\`\`\``, false); + sendBotMessage(getCurrentChannel().id, { content: `*Checking for any groups in this channel...*\n> If none is found, a new one will be created \n> [Tip] You can do \`/encryptcord leave\` to leave a group` }); + await sleep(5000); + if (await DataStore.get("encryptcordGroup") === true && (await DataStore.get("encryptcordChannelId") !== getCurrentChannel().id)) { + sendBotMessage(getCurrentChannel().id, { content: "*Leaving current group...*" }); + await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } }); + } else if (await DataStore.get("encryptcordGroup") === true) { + setButtonDisabled(false); + return; + } + await startGroup("", { channel: { id: getCurrentChannel().id } }); + } + setEnabled(!enabled); + setButtonDisabled(false); + }} + buttonProps={{ + style: { + transition: "transform 0.3s ease-in-out", + transform: `rotate(${enabled ? 0 : 15}deg)`, + }, + disabled: buttonDisabled + }} + > + + {!enabled && <> + + + + + } + + + + ); +}; + +export default definePlugin({ + name: "Encryptcord", + description: "End-to-end encryption in Discord!", + authors: [Devs.Inbestigator], + dependencies: ["CommandsAPI"], + patches: [ + { + find: "INTERACTION_APPLICATION_COMMAND_INVALID_VERSION", + replacement: { + match: /await\s.{1,2}\..{1,2}\.post\(\{url:.{1,2}\.ANM\.INTERACTIONS,body:(.),/g, + replace: "await $self.interactionHandler($1);$&" + } + } + ], + async interactionHandler(interaction) { + const sender = await UserUtils.getUser(interaction.application_id).catch(() => null); + if (!sender || (sender.bot === true && sender.id !== "1")) return; + if (interaction.data.component_type !== 2) return; + switch (interaction.data.custom_id) { + case "removeFromSelf": + await handleLeaving(sender.id, await DataStore.get("encryptcordGroupMembers") ?? {}, interaction.channel_id); + await sendTempMessage(sender.id, "", "leaving"); + FluxDispatcher.dispatch({ + type: "MESSAGE_DELETE", + channelId: interaction.channel_id, + id: interaction.message_id, + mlDeleted: true + }); + break; + case "createGroup": + await leave("", { channel: { id: interaction.channel_id } }); + await startGroup("", { channel: { id: interaction.channel_id } }); + break; + default: + return; + } + }, + flux: { + async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) { + if (optimistic || type !== "MESSAGE_CREATE") return; + if (message.state === "SENDING") return; + if (message.author.id === UserStore.getCurrentUser().id) return; + if (!message.content) return; + const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers"); + if (!Object.keys(encryptcordGroupMembers).some(key => key === message.author.id)) { + switch (message.content.toLowerCase().split("```")[0]) { + case "groupdata": + const response = await fetch(message.attachments[0].url); + const groupdata = await response.json(); + await handleGroupData(groupdata); + break; + case "join": + if (encryptcordGroupMembers[UserStore.getCurrentUser().id].child) return; + if (!await DataStore.get("encryptcordGroup")) return; + if (await DataStore.get("encryptcordChannelId") !== message.channel_id) return; + const sender = await UserUtils.getUser(message.author.id).catch(() => null); + if (!sender) return; + const userKey = message.content.split("```")[1]; + await handleJoin(sender.id, userKey, encryptcordGroupMembers); + break; + default: + break; + } + return; + } + const dmChannelId = await PrivateChannelsStore.getOrEnsurePrivateChannel(message.author.id); + if (channelId !== dmChannelId) return; + const sender = await UserUtils.getUser(message.author.id).catch(() => null); + if (!sender) return; + const groupChannel = await DataStore.get("encryptcordChannelId"); + switch (message.content.toLowerCase()) { + case "leaving": + handleLeaving(sender.id, encryptcordGroupMembers, groupChannel); + break; + case "message": + const msgResponse = await fetch(message.attachments[0].url); + const messagedata = await msgResponse.json(); + await handleMessage(messagedata, sender.id, groupChannel); + break; + case "groupdata": + const response = await fetch(message.attachments[0].url); + const groupdata = await response.json(); + await handleGroupData(groupdata); + break; + default: + break; + } + }, + }, + commands: [ + { + name: "encryptcord", + description: "End-to-end encryption in Discord!", + options: [ + { + name: "leave", + description: "Leave current group", + options: [], + type: ApplicationCommandOptionType.SUB_COMMAND, + }, + { + name: "data", + description: "View your keys and current group members", + options: [], + type: ApplicationCommandOptionType.SUB_COMMAND, + }, + ], + inputType: ApplicationCommandInputType.BUILT_IN, + execute: (opts, ctx) => { + switch (opts[0].name) { + case "start": + startGroup(opts[0].options, ctx); + break; + case "leave": + leave(opts[0].options, ctx); + break; + case "data": + data(opts[0].options, ctx); + break; + } + }, + }, + ], + async start() { + addChatBarButton("Encryptcord", ChatBarIcon); + const pair = await generateKeys(); + await DataStore.set("encryptcordPublicKey", pair.publicKey); + await DataStore.set("encryptcordPrivateKey", pair.privateKey); + if (await DataStore.get("encryptcordGroup") === true) { + await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } }); + } + await DataStore.set("encryptcordGroup", false); + await DataStore.set("encryptcordChannelId", ""); + await DataStore.set("encryptcordGroupMembers", {}); + }, + async stop() { + removeButton("Encryptcord"); + if (await DataStore.get("encryptcordGroup") === true) { + await leave("", { channel: { id: await DataStore.get("encryptcordChannelId") } }); + } + }, +}); + +// Send Temporary Message +async function sendTempMessage(recipientId: string, attachment: string, content: string, dm: boolean = true) { + if (recipientId === UserStore.getCurrentUser().id) return; + const channelId = dm ? await PrivateChannelsStore.getOrEnsurePrivateChannel(recipientId) : recipientId; + if (attachment && attachment !== "") { + const upload = await new CloudUpload({ + file: new File([new Blob([attachment])], "file.text", { type: "text/plain; charset=utf-8" }), + isClip: false, + isThumbnail: false, + platform: 1, + }, channelId, false, 0); + upload.on("complete", async () => { + const messageId = await RestAPI.post({ + url: `/channels/${channelId}/messages`, + body: { + content, + attachments: [{ + id: "0", + filename: upload.filename, + uploaded_filename: upload.uploadedFilename, + }], + nonce: SnowflakeUtils.fromTimestamp(Date.now()), + }, + }).then(response => response.body.id); + + await sleep(500); + MessageActions.deleteMessage(channelId, messageId); + }); + await upload.upload(); + return; + } + + const messageId = await RestAPI.post({ + url: `/channels/${channelId}/messages`, + body: { + content, + nonce: SnowflakeUtils.fromTimestamp(Date.now()), + }, + }).then(response => response.body.id); + + await sleep(500); + MessageActions.deleteMessage(channelId, messageId); +} + +// Handle leaving group +async function handleLeaving(senderId: string, encryptcordGroupMembers: object, groupChannel: string) { + const updatedMembers = Object.keys(encryptcordGroupMembers).reduce((result, memberId) => { + if (memberId !== senderId) { + result[memberId] = encryptcordGroupMembers[memberId]; + if (result[memberId].child === senderId) { + result[memberId].child = encryptcordGroupMembers[senderId].child; + } + if (result[memberId].parent === senderId) { + result[memberId].parent = encryptcordGroupMembers[senderId].parent; + } + } + return result; + }, {}); + + await DataStore.set("encryptcordGroupMembers", updatedMembers); + + await MessageActions.receiveMessage(groupChannel, await createMessage("", senderId, groupChannel, 2)); +} + +// Handle receiving message +async function handleMessage(message, senderId: string, groupChannel: string) { + const decryptedMessage = await decryptData(await DataStore.get("encryptcordPrivateKey"), message); + await MessageActions.receiveMessage(groupChannel, await createMessage(decryptedMessage, senderId, groupChannel, 0)); +} + +// Handle receiving group data +async function handleGroupData(groupData) { + await DataStore.set("encryptcordChannelId", groupData.channel); + await DataStore.set("encryptcordGroupMembers", groupData.members); + await DataStore.set("encryptcordGroup", true); + await MessageActions.receiveMessage(groupData.channel, await createMessage("", UserStore.getCurrentUser().id, groupData.channel, 7)); + setEnabled(true); +} + +// Handle joining group +async function handleJoin(senderId: string, senderKey: string, encryptcordGroupMembers: object) { + encryptcordGroupMembers[senderId] = { key: senderKey, parent: UserStore.getCurrentUser().id, child: null }; + encryptcordGroupMembers[UserStore.getCurrentUser().id].child = senderId; + await DataStore.set("encryptcordGroupMembers", encryptcordGroupMembers); + const groupChannel = await DataStore.get("encryptcordChannelId"); + const newMember = await UserUtils.getUser(senderId).catch(() => null); + if (!newMember) return; + + const membersData = {}; + Object.entries(encryptcordGroupMembers) + .forEach(([memberId, value]) => { + membersData[memberId] = value; + }); + + const membersDataString = JSON.stringify({ members: membersData, channel: groupChannel }); + + const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => { + const groupMember = await UserUtils.getUser(memberId).catch(() => null); + if (!groupMember) return; + await sendTempMessage(groupMember.id, membersDataString, "groupdata"); + }); + + await Promise.all(dmPromises); + await MessageActions.receiveMessage(groupChannel, { + ...await createMessage("", senderId, groupChannel, 7), components: [{ + type: 1, + components: [{ + type: 2, + style: 4, + label: "I don't want to talk to you!", + custom_id: "removeFromSelf" + }, + { + type: 2, + style: 2, + label: "(Other users can still send/receive messages to/from them)", + disabled: true, + custom_id: "encryptcord" + }] + }] + }); +} + +// Create message for group +async function createMessage(message: string, senderId: string, channelId: string, type: number) { + const messageStart = sendBotMessage("", { channel_id: channelId, embeds: [] }); + const sender = await UserUtils.getUser(senderId).catch(() => null); + if (!sender) return; + return { ...messageStart, content: message, author: sender, type, flags: 0 }; +} + +// Start E2EE Group +async function startGroup(opts, ctx) { + const channelId = ctx.channel.id; + await DataStore.set("encryptcordChannelId", channelId); + await DataStore.set("encryptcordGroupMembers", { + [UserStore.getCurrentUser().id]: { key: await DataStore.get("encryptcordPublicKey"), parent: null, child: null } + }); + await DataStore.set("encryptcordGroup", true); + sendBotMessage(channelId, { content: "Group created!\n> Other users can click the lock icon to join." }); + await MessageActions.receiveMessage(channelId, await createMessage("", UserStore.getCurrentUser().id, channelId, 7)); + setEnabled(true); +} + +// Leave the Group; +async function leave(opts, ctx) { + const channelId = ctx.channel.id; + if (!(await DataStore.get("encryptcordGroup"))) { + sendBotMessage(channelId, { content: "You're not in a group!" }); + return; + } + const user = UserStore.getCurrentUser(); + const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers"); + + const dmPromises = Object.keys(encryptcordGroupMembers).map(async memberId => { + const groupMember = await UserUtils.getUser(memberId).catch(() => null); + if (!groupMember) return; + await sendTempMessage(groupMember.id, "", "leaving"); + }); + + await Promise.all(dmPromises); + await DataStore.set("encryptcordGroup", false); + await DataStore.set("encryptcordChannelId", ""); + await DataStore.set("encryptcordGroupMembers", {}); + await MessageActions.receiveMessage(channelId, await createMessage("", user.id, channelId, 2)); + setEnabled(false); +} + +// View user data +async function data(opts, ctx) { + const channelId = ctx.channel.id; + const encryptcordGroupMembers = await DataStore.get("encryptcordGroupMembers"); + const encryptcordPublicKey = await DataStore.get("encryptcordPublicKey"); + const encryptcordPrivateKey = await DataStore.get("encryptcordPrivateKey"); + const exportedPrivateKey = await crypto.subtle.exportKey("pkcs8", encryptcordPrivateKey); + const groupMembers = Object.keys(encryptcordGroupMembers); + sendBotMessage(channelId, { + 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)}\`\`\`` + }); +} diff --git a/src/equicordplugins/encryptcord/rsa-utils.tsx b/src/equicordplugins/encryptcord/rsa-utils.tsx new file mode 100644 index 00000000..15d272d5 --- /dev/null +++ b/src/equicordplugins/encryptcord/rsa-utils.tsx @@ -0,0 +1,121 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const generateKeys = async () => { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["encrypt", "decrypt"] + ); + + const exportedPublicKey = await crypto.subtle.exportKey("spki", keyPair.publicKey); + const publicKey = formatPemKey(exportedPublicKey, "public"); + + return { privateKey: keyPair.privateKey, publicKey }; +}; + +export const encryptData = async (pemPublicKey, data) => { + const publicKey = await importPemPublicKey(pemPublicKey); + + const chunkSize = 446; + + const encryptedChunks: any[] = []; + const encoder = new TextEncoder(); + + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = await data.substring(i, i + chunkSize); + const encryptedChunk = await crypto.subtle.encrypt( + { + name: "RSA-OAEP", + }, + publicKey, + encoder.encode(chunk) + ); + encryptedChunks.push(arrayBufferToBase64(encryptedChunk)); + } + + return encryptedChunks; +}; + +export const decryptData = async (privateKey, encArray) => { + const decryptionPromises = encArray.map(async encStr => { + const encBuffer = base64ToArrayBuffer(encStr); + + const dec = await crypto.subtle.decrypt( + { + name: "RSA-OAEP", + }, + privateKey, + encBuffer + ); + + return new TextDecoder().decode(dec); + }); + + const decryptedMessages = await Promise.all(decryptionPromises); + + return decryptedMessages.join(""); +}; + +// Helper functions +const arrayBufferToBase64 = buffer => { + const binary = String.fromCharCode(...new Uint8Array(buffer)); + return btoa(binary); +}; + +const base64ToArrayBuffer = base64String => { + const binaryString = atob(base64String); + const { length } = binaryString; + const buffer = new ArrayBuffer(length); + const view = new Uint8Array(buffer); + + for (let i = 0; i < length; i++) { + view[i] = binaryString.charCodeAt(i); + } + + return buffer; +}; + +export const formatPemKey = (keyData, type) => { + const base64Key = arrayBufferToBase64(keyData); + return `-----BEGIN ${type.toUpperCase()} KEY-----\n` + base64Key + `\n-----END ${type.toUpperCase()} KEY----- `; +}; + +const importPemPublicKey = async pemKey => { + try { + const trimmedPemKey = pemKey.trim(); + + const keyBody = trimmedPemKey + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", ""); + + const binaryDer = atob(keyBody); + + const arrayBuffer = new Uint8Array(binaryDer.length); + for (let i = 0; i < binaryDer.length; i++) { + arrayBuffer[i] = binaryDer.charCodeAt(i); + } + + return await crypto.subtle.importKey( + "spki", + arrayBuffer, + { + name: "RSA-OAEP", + hash: { name: "SHA-256" }, + }, + true, + ["encrypt"] + ); + } catch (error) { + console.error("Error importing PEM public key:", error); + throw error; + } +};