diff --git a/src/plugins/baseConverter/BaseConverterAccessory.tsx b/src/plugins/baseConverter/BaseConverterAccessory.tsx new file mode 100644 index 0000000000..1b3ccd1866 --- /dev/null +++ b/src/plugins/baseConverter/BaseConverterAccessory.tsx @@ -0,0 +1,164 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { Message } from "@vencord/discord-types"; +import { ChannelStore, Parser, useEffect, useRef, useState, UserStore } from "@webpack/common"; + +import { BaseConverterIcon } from "./BaseConverterIcon"; +import { settings } from "./settings"; +import { cl, ConversionResult, decode, EncodingType } from "./utils"; + +const ConversionSetters = new Map void>(); +const DecodedMessages = new Map(); +const ReplyListeners = new Map void>>(); + +function notifyDecode(messageId: string, data: ConversionResult) { + DecodedMessages.set(messageId, data); + ReplyListeners.get(messageId)?.forEach(fn => fn(data)); +} + +export function handleDecode(messageId: string, data: ConversionResult) { + notifyDecode(messageId, data); + ConversionSetters.get(messageId)?.(data); +} + +function findMessageContentEl(messageId: string): HTMLElement | null { + return document.getElementById(`message-content-${messageId}`); +} + +export function BaseConverterAccessory({ message }: { message: Message; }) { + const { autoDecodeReceived, receiveEncoding, aesSecret, userKeys } = settings.use(["autoDecodeReceived", "receiveEncoding", "aesSecret", "userKeys"]); + const authorId: string | undefined = (message as any).author?.id; + const currentUserId = UserStore.getCurrentUser()?.id; + + // For your own messages in a DM the author is YOU, so userKeys[authorId] is + // meaningless. Use the DM partner's key instead, since that's who you encoded for. + let effectiveKey: string; + if (authorId && authorId === currentUserId) { + const channel = ChannelStore.getChannel(message.channel_id); + const partnerRaw = (channel as any)?.recipients?.[0]; + const partnerId: string | undefined = typeof partnerRaw === "string" ? partnerRaw : partnerRaw?.id; + effectiveKey = (partnerId && userKeys?.[partnerId]) ? userKeys[partnerId] : aesSecret; + } else { + effectiveKey = (authorId && userKeys?.[authorId]) ? userKeys[authorId] : aesSecret; + } + const [result, setResult] = useState(); + const [showOriginal, setShowOriginal] = useState(false); + const containerRef = useRef(null); + + const referencedMessageId = (message as any).messageReference?.messageId + ?? (message as any).messageReference?.message_id; + + const [referenceResult, setReferenceResult] = useState( + () => referencedMessageId ? DecodedMessages.get(referencedMessageId) : undefined + ); + + useEffect(() => { + if (!referencedMessageId) return; + const set = ReplyListeners.get(referencedMessageId) ?? new Set(); + set.add(setReferenceResult); + ReplyListeners.set(referencedMessageId, set); + return () => { + set.delete(setReferenceResult); + if (!set.size) ReplyListeners.delete(referencedMessageId); + }; + }, [referencedMessageId]); + + useEffect(() => { + if ((message as any).vencordEmbeddedBy) return; + + ConversionSetters.set(message.id, setResult); + + if (autoDecodeReceived && message.content) { + decode(message.content, receiveEncoding as EncodingType, effectiveKey) + .then(decoded => { + if (decoded) { + setResult(decoded); + notifyDecode(message.id, decoded); + } + }) + .catch(() => { /* silent — auto-decode is best-effort */ }); + } + + return () => void ConversionSetters.delete(message.id); + }, [message.id, autoDecodeReceived, receiveEncoding, effectiveKey]); + + // Hide the original encrypted message content when decoded; show when toggled + useEffect(() => { + const mc = findMessageContentEl(message.id); + if (!mc) return; + mc.style.display = result && !showOriginal ? "none" : ""; + return () => { mc.style.display = ""; }; + }, [result, showOriginal]); + + // Hide the reply bar's encoded reference text and show the decoded version + useEffect(() => { + if (!referenceResult) return; + + const listItem = document.querySelector( + `li[id*="${message.id}"], [data-list-item-id*="${message.id}"]` + ); + if (!listItem) return; + + const replyContent = listItem.querySelector( + "[class*='repliedMessage'] [class*='messageContent']" + ); + if (!replyContent || !replyContent.parentElement) return; + + replyContent.style.display = "none"; + const decoded = document.createElement("span"); + decoded.textContent = referenceResult.text; + replyContent.parentElement.insertBefore(decoded, replyContent); + + return () => { + replyContent.style.display = ""; + decoded.remove(); + }; + }, [referenceResult]); + + // Match decoded text color to the actual message content color + useEffect(() => { + if (!result || !containerRef.current) return; + const mc = findMessageContentEl(message.id); + if (!mc) return; + const color = window.getComputedStyle(mc).color; + containerRef.current.style.color = color; + return () => { if (containerRef.current) containerRef.current.style.color = ""; }; + }, [result]); + + if (!result) return null; + + return ( + + + {Parser.parse(result.text)} +
+ + {result.encoding} + {" — "} + + {" — "} + + +
+ ); +} diff --git a/src/plugins/baseConverter/BaseConverterIcon.tsx b/src/plugins/baseConverter/BaseConverterIcon.tsx new file mode 100644 index 0000000000..4760fa3e80 --- /dev/null +++ b/src/plugins/baseConverter/BaseConverterIcon.tsx @@ -0,0 +1,97 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; +import { TooltipContainer } from "@components/TooltipContainer"; +import { classes } from "@utils/misc"; +import { IconComponent } from "@utils/types"; +import { useEffect, useState } from "@webpack/common"; + +import { settings } from "./settings"; +import { openBaseConverterModal } from "./BaseConverterModal"; +import { cl } from "./utils"; + +/** + * Binary bars → arrow → text lines icon. + * Three vertical bars on the left represent binary digits (tall=1, short=0), + * a filled arrow points right, and three horizontal lines suggest decoded text. + */ +export const BaseConverterIcon: IconComponent = ({ height = 20, width = 20, className }) => ( + + {/* Binary bars: 1 0 1 */} + + + + + {/* Arrow shaft */} + + {/* Arrow head (filled triangle) */} + + + {/* Text lines (decoded content representation) */} + + + +); + +export let setShouldShowAutoEncodeTooltip: undefined | ((show: boolean) => void); + +export const BaseConverterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => { + const { autoEncodeOutgoing } = settings.use(["autoEncodeOutgoing"]); + + const [shouldShowTooltip, setter] = useState(false); + useEffect(() => { + setShouldShowAutoEncodeTooltip = setter; + return () => { setShouldShowAutoEncodeTooltip = undefined; }; + }, []); + + if (!isMainChat) return null; + + const toggle = () => { + settings.store.autoEncodeOutgoing = !autoEncodeOutgoing; + }; + + const button = ( + { + if (e.shiftKey) return toggle(); + openBaseConverterModal(); + }} + onContextMenu={toggle} + buttonProps={{ "aria-haspopup": "dialog" }} + > + + + ); + + if (shouldShowTooltip && autoEncodeOutgoing) + return ( + + {button} + + ); + + return button; +}; diff --git a/src/plugins/baseConverter/BaseConverterModal.tsx b/src/plugins/baseConverter/BaseConverterModal.tsx new file mode 100644 index 0000000000..ef58f379ac --- /dev/null +++ b/src/plugins/baseConverter/BaseConverterModal.tsx @@ -0,0 +1,201 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { BaseText } from "@components/BaseText"; +import { Divider } from "@components/Divider"; +import { FormSwitch } from "@components/FormSwitch"; +import { Margins } from "@utils/margins"; +import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; +import { Forms, SearchableSelect, UserStore, useState } from "@webpack/common"; + +import { settings } from "./settings"; +import { openUserKeyModal } from "./UserKeyModal"; +import { cl, DECODE_OPTIONS, ENCODE_OPTIONS } from "./utils"; + +function EncodingSelect({ + label, + settingsKey, + options, +}: { + label: string; + settingsKey: "receiveEncoding" | "sendEncoding"; + options: typeof DECODE_OPTIONS | typeof ENCODE_OPTIONS; +}) { + const currentValue = settings.use([settingsKey])[settingsKey]; + + return ( +
+ {label} + (settings.store[settingsKey] = v)} + /> +
+ ); +} + +function AesSecretInput() { + const aesSecret = settings.use(["aesSecret"]).aesSecret; + const [visible, setVisible] = useState(false); + + return ( +
+ Shared AES-256-GCM Secret Key + + Both users must enter the exact same key to encrypt and decrypt each other's messages. + The key never leaves your device — all encryption happens locally. + It is stored in plain text in your local Vencord settings. + +
+ { settings.store.aesSecret = e.currentTarget.value; }} + placeholder="Enter shared secret…" + autoComplete="off" + spellCheck={false} + /> + +
+
+ ); +} + +function AutoDecodeToggle() { + const value = settings.use(["autoDecodeReceived"]).autoDecodeReceived; + return ( + (settings.store.autoDecodeReceived = v)} + hideBorder + /> + ); +} + +function AutoEncodeToggle() { + const value = settings.use(["autoEncodeOutgoing"]).autoEncodeOutgoing; + return ( + (settings.store.autoEncodeOutgoing = v)} + hideBorder + /> + ); +} + +function UserKeysSection() { + const userKeys = settings.use(["userKeys"]).userKeys ?? {}; + const entries = Object.entries(userKeys); + + if (entries.length === 0) return null; + + return ( + <> + +
+ Per-User AES Keys + + These keys override the global secret for specific users. Right-click a user to add or update a key. + + {entries.map(([userId]) => { + const username = UserStore.getUser(userId)?.username ?? userId; + return ( +
+ @{username} + + +
+ ); + })} +
+ + ); +} + +function BaseConverterModal({ rootProps }: { rootProps: ModalProps; }) { + return ( + + + + Base Converter + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function openBaseConverterModal() { + openModal(props => ); +} diff --git a/src/plugins/baseConverter/UserKeyModal.tsx b/src/plugins/baseConverter/UserKeyModal.tsx new file mode 100644 index 0000000000..743a5f3873 --- /dev/null +++ b/src/plugins/baseConverter/UserKeyModal.tsx @@ -0,0 +1,117 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { BaseText } from "@components/BaseText"; +import { Margins } from "@utils/margins"; +import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; +import { Forms, useState } from "@webpack/common"; + +import { settings } from "./settings"; +import { cl } from "./utils"; + +function UserKeyModal({ rootProps, userId, username }: { rootProps: ModalProps; userId: string; username: string; }) { + const existingKey = settings.store.userKeys?.[userId] ?? ""; + const [key, setKey] = useState(existingKey); + const [visible, setVisible] = useState(false); + + const save = () => { + const trimmed = key.trim(); + if (!trimmed) return; + settings.store.userKeys = { ...settings.store.userKeys, [userId]: trimmed }; + rootProps.onClose(); + }; + + const clear = () => { + const { [userId]: _, ...rest } = settings.store.userKeys ?? {}; + settings.store.userKeys = rest; + rootProps.onClose(); + }; + + return ( + + + + AES Key — @{username} + + + + + +
+ Per-User AES-256-GCM Secret Key + + This key overrides the global secret when sending to or auto-decoding messages from @{username}. + Both users must enter the exact same key. Stored in plain text in your local Vencord settings. + +
+ setKey(e.currentTarget.value)} + onKeyDown={e => { if (e.key === "Enter") save(); }} + placeholder="Enter shared secret…" + autoComplete="off" + spellCheck={false} + autoFocus + /> + +
+
+ +
+ + {existingKey && ( + + )} + +
+
+
+ ); +} + +export function openUserKeyModal(userId: string, username: string) { + openModal(props => ); +} diff --git a/src/plugins/baseConverter/index.tsx b/src/plugins/baseConverter/index.tsx new file mode 100644 index 0000000000..4219bd6f24 --- /dev/null +++ b/src/plugins/baseConverter/index.tsx @@ -0,0 +1,181 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import "./styles.css"; + +import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; +import definePlugin from "@utils/types"; +import { Message, User } from "@vencord/discord-types"; +import { ChannelStore, Menu, showToast, Toasts } from "@webpack/common"; + +import { handleDecode, BaseConverterAccessory } from "./BaseConverterAccessory"; +import { BaseConverterChatBarIcon, BaseConverterIcon, setShouldShowAutoEncodeTooltip } from "./BaseConverterIcon"; +import { settings } from "./settings"; +import { openUserKeyModal } from "./UserKeyModal"; +import { decode, encode, EncodingType, EncodeTarget } from "./utils"; + +function getMessageContent(message: Message): string { + return message.content + || message.messageSnapshots?.[0]?.message.content + || message.embeds?.find(e => e.type === "auto_moderation_message")?.rawDescription + || ""; +} + +const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => { + const content = getMessageContent(message); + if (!content) return; + + const group = findGroupChildrenByChildId("copy-text", children); + if (!group) return; + + group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, ( + { + const authorId: string | undefined = (message as any).author?.id; + const aesKey = (authorId && settings.store.userKeys?.[authorId]) + ? settings.store.userKeys[authorId] + : settings.store.aesSecret; + const result = await decode( + content, + settings.store.receiveEncoding as EncodingType, + aesKey + ); + if (result) { + handleDecode(message.id, result); + } else { + showToast("Could not decode this message. Check the encoding setting and (for AES) your shared secret.", Toasts.Type.FAILURE); + } + }} + /> + )); +}; + +const userContextPatch: NavContextMenuPatchCallback = (children, { user }: { user?: User; }) => { + if (!user) return; + + const item = ( + openUserKeyModal(user.id, user.username)} + /> + ); + + // In DM sidebar: insert before "Close DM" so the item is in the visible section. + // In server / other contexts: find the devmode group or fall back to appending. + const dmGroup = findGroupChildrenByChildId("close-dm", children); + if (dmGroup) { + const idx = dmGroup.findIndex(c => c?.props?.id === "close-dm"); + dmGroup.splice(idx, 0, item); + return; + } + + const devGroup = findGroupChildrenByChildId(`devmode-copy-id-${user.id}`, children); + (devGroup ?? children).splice(-1, 0, item); +}; + +let tooltipTimeout: ReturnType; + +export default definePlugin({ + name: "BaseConverter", + description: "Decode and encode messages between binary, octal, decimal, hex, base32, base64, UTF-8, and AES-256-GCM — directly in chat.", + tags: ["Chat", "Utility"], + authors: [{ name: "YourName", id: 0n }], + + settings, + + contextMenus: { + "message": messageCtxPatch, + "user-context": userContextPatch, + }, + + renderMessageAccessory: props => , + + chatBarButton: { + icon: BaseConverterIcon, + render: BaseConverterChatBarIcon, + }, + + messagePopoverButton: { + icon: BaseConverterIcon, + render(message: Message) { + const content = getMessageContent(message); + if (!content) return null; + + return { + label: "Decode Message", + icon: BaseConverterIcon, + message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: async () => { + const authorId: string | undefined = (message as any).author?.id; + const aesKey = (authorId && settings.store.userKeys?.[authorId]) + ? settings.store.userKeys[authorId] + : settings.store.aesSecret; + const result = await decode( + content, + settings.store.receiveEncoding as EncodingType, + aesKey + ); + if (result) { + handleDecode(message.id, result); + } else { + showToast("Could not decode this message. Check the encoding setting and (for AES) your shared secret.", Toasts.Type.FAILURE); + } + }, + }; + }, + }, + + async onBeforeMessageSend(channelId, message) { + if (!settings.store.autoEncodeOutgoing) return; + if (!message.content) return; + + // For 1-on-1 DMs, prefer the per-user key for the recipient. + // Guard: only apply when there is exactly one recipient (DM, not group DM or server). + // Recipients may be stored as strings (user IDs) or user objects — handle both. + let aesKey = settings.store.aesSecret; + const channel = ChannelStore.getChannel(channelId); + const recipients: unknown[] = (channel as any)?.recipients ?? []; + if (recipients.length === 1) { + const raw = recipients[0]; + const recipientId: string | undefined = typeof raw === "string" ? raw : (raw as any)?.id; + const userKey = recipientId ? settings.store.userKeys?.[recipientId] : undefined; + if (userKey) aesKey = userKey; + } + + if (settings.store.sendEncoding === "aes" && !aesKey) { + showToast("Set a shared AES secret in the Base Converter settings before sending.", Toasts.Type.FAILURE); + return; + } + + setShouldShowAutoEncodeTooltip?.(true); + clearTimeout(tooltipTimeout); + tooltipTimeout = setTimeout(() => setShouldShowAutoEncodeTooltip?.(false), 2000); + + message.content = await encode( + message.content, + settings.store.sendEncoding as EncodeTarget, + aesKey + ); + }, +}); diff --git a/src/plugins/baseConverter/settings.tsx b/src/plugins/baseConverter/settings.tsx new file mode 100644 index 0000000000..bdf01b6947 --- /dev/null +++ b/src/plugins/baseConverter/settings.tsx @@ -0,0 +1,67 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { Button } from "@components/Button"; +import { OptionType } from "@utils/types"; + +import { openBaseConverterModal } from "./BaseConverterModal"; +import { DECODE_OPTIONS, ENCODE_OPTIONS } from "./utils"; + +export const settings = definePluginSettings({ + receiveEncoding: { + type: OptionType.SELECT, + description: "Encoding to decode received messages from", + options: DECODE_OPTIONS, + default: "auto", + hidden: true, + }, + sendEncoding: { + type: OptionType.SELECT, + description: "Encoding to use for outgoing messages", + options: ENCODE_OPTIONS, + default: "binary", + hidden: true, + }, + autoDecodeReceived: { + type: OptionType.BOOLEAN, + description: "Automatically decode encoded incoming messages (uses Auto-Detect unless a specific encoding is chosen)", + default: false, + }, + autoEncodeOutgoing: { + type: OptionType.BOOLEAN, + description: "Automatically encode your messages before sending. Shift+click or right-click the chat bar button to toggle", + default: false, + }, + aesSecret: { + type: OptionType.STRING, + description: "Shared AES-256-GCM secret key — both users must use the same value. Stored in plain text in Vencord settings.", + default: "", + hidden: true, + }, + manageSettings: { + type: OptionType.COMPONENT, + component: () => ( + + ), + }, +}).withPrivateSettings<{ + userKeys: Record; +}>(); diff --git a/src/plugins/baseConverter/styles.css b/src/plugins/baseConverter/styles.css new file mode 100644 index 0000000000..3829aad975 --- /dev/null +++ b/src/plugins/baseConverter/styles.css @@ -0,0 +1,186 @@ +.vc-baseconv-modal-content { + padding: 1em; +} + +.vc-baseconv-modal-header { + place-content: center space-between; +} + +.vc-baseconv-modal-title { + margin: 0; +} + +.vc-baseconv-accessory { + color: inherit; + font-style: normal; + font-weight: inherit; + line-height: 1.375; + white-space: break-spaces; +} + +.vc-baseconv-accessory-icon { + margin-right: 0.25em; + vertical-align: text-bottom; + opacity: 0.5; +} + +.vc-baseconv-decoded-text { + color: inherit; +} + +.vc-baseconv-meta { + display: inline; + color: var(--text-muted); + font-size: 0.8125rem; + font-style: italic; +} + +.vc-baseconv-encoding-label { + color: var(--interactive-active); + font-weight: 600; + font-style: normal; +} + +.vc-baseconv-toggle-original { + all: unset; + cursor: pointer; + color: var(--text-link); +} + +.vc-baseconv-toggle-original:is(:hover, :focus) { + text-decoration: underline; +} + +.vc-baseconv-dismiss { + all: unset; + cursor: pointer; + color: var(--text-link); +} + +.vc-baseconv-dismiss:is(:hover, :focus) { + text-decoration: underline; +} + +.vc-baseconv-auto-encode { + color: var(--green-360); +} + +.vc-baseconv-chat-button { + scale: 1.085; +} + +/* AES secret row */ +.vc-baseconv-secret-row { + display: flex; + gap: 0.5em; + align-items: center; +} + +.vc-baseconv-secret-input { + flex: 1; + padding: 0.5em 0.75em; + background: var(--input-background); + border: 1px solid var(--input-background); + border-radius: 3px; + color: var(--text-normal); + font-size: 1rem; + outline: none; + box-sizing: border-box; + font-family: var(--font-code); +} + +.vc-baseconv-secret-input:focus { + border-color: var(--brand-experiment); +} + +.vc-baseconv-secret-input::placeholder { + color: var(--text-muted); +} + +.vc-baseconv-secret-toggle { + all: unset; + cursor: pointer; + padding: 0.5em 0.75em; + background: var(--button-secondary-background); + border-radius: 3px; + color: var(--text-normal); + font-size: 0.875rem; + white-space: nowrap; +} + +.vc-baseconv-secret-toggle:hover { + background: var(--button-secondary-background-hover); +} + +/* Per-user key modal actions */ +.vc-baseconv-user-key-actions { + display: flex; + gap: 0.5em; + flex-wrap: wrap; +} + +.vc-baseconv-user-key-btn { + all: unset; + cursor: pointer; + padding: 0.45em 1em; + border-radius: 3px; + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; +} + +.vc-baseconv-user-key-save { + background: var(--button-positive-background); + color: #fff; +} + +.vc-baseconv-user-key-save:hover { + background: var(--button-positive-background-hover, var(--button-positive-background)); +} + +.vc-baseconv-user-key-save:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.vc-baseconv-user-key-clear { + background: var(--button-danger-background); + color: #fff; +} + +.vc-baseconv-user-key-clear:hover { + background: var(--button-danger-background-hover, var(--button-danger-background)); +} + +.vc-baseconv-user-key-cancel { + background: var(--button-secondary-background); + color: var(--text-normal); +} + +.vc-baseconv-user-key-cancel:hover { + background: var(--button-secondary-background-hover); +} + +.vc-baseconv-user-key-edit { + background: var(--button-secondary-background); + color: var(--text-normal); +} + +.vc-baseconv-user-key-edit:hover { + background: var(--button-secondary-background-hover); +} + +/* Per-user key list rows in the main modal */ +.vc-baseconv-user-key-row { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.5em; +} + +.vc-baseconv-user-key-name { + flex: 1; + font-family: var(--font-code); + font-size: 0.875rem; + color: var(--text-normal); +} diff --git a/src/plugins/baseConverter/utils.ts b/src/plugins/baseConverter/utils.ts new file mode 100644 index 0000000000..c231744c59 --- /dev/null +++ b/src/plugins/baseConverter/utils.ts @@ -0,0 +1,382 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2024 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { classNameFactory } from "@utils/css"; + +export const cl = classNameFactory("vc-baseconv-"); + +export type EncodingType = "auto" | "binary" | "octal" | "decimal" | "hex" | "base32" | "base64" | "utf8" | "aes"; +export type EncodeTarget = Exclude; + +export interface ConversionResult { + text: string; + encoding: string; +} + +export const ENCODING_LABELS: Record = { + auto: "Auto-Detect", + binary: "Binary (Base 2)", + octal: "Octal (Base 8)", + decimal: "Decimal (Base 10)", + hex: "Hexadecimal (Base 16)", + base32: "Base 32", + base64: "Base 64", + utf8: "UTF-8 Bytes", + aes: "AES-256-GCM Encrypted", +}; + +export const DECODE_OPTIONS = [ + { label: "Auto-Detect", value: "auto", default: true }, + { label: "Binary (Base 2)", value: "binary" }, + { label: "Octal (Base 8)", value: "octal" }, + { label: "Decimal (Base 10)", value: "decimal" }, + { label: "Hexadecimal (Base 16)", value: "hex" }, + { label: "Base 32", value: "base32" }, + { label: "Base 64", value: "base64" }, + { label: "UTF-8 Bytes", value: "utf8" }, + { label: "AES-256-GCM Encrypted", value: "aes" }, +] as const; + +export const ENCODE_OPTIONS = [ + { label: "Binary (Base 2)", value: "binary", default: true }, + { label: "Octal (Base 8)", value: "octal" }, + { label: "Decimal (Base 10)", value: "decimal" }, + { label: "Hexadecimal (Base 16)", value: "hex" }, + { label: "Base 32", value: "base32" }, + { label: "Base 64", value: "base64" }, + { label: "UTF-8 Bytes", value: "utf8" }, + { label: "AES-256-GCM Encrypted", value: "aes" }, +] as const; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToUint8(base64: string): Uint8Array { + const binary = atob(base64.trim()); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// ─── AES Key Derivation (cached per secret) ─────────────────────────────────── + +// Derive the key once per secret value and reuse it. PBKDF2 with 100k iterations +// is intentionally slow for brute-force resistance; caching amortizes that cost +// to the first encode/decode after the secret changes. +const keyCache = new Map(); + +async function getAesKey(secret: string): Promise { + if (keyCache.has(secret)) return keyCache.get(secret)!; + + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + "PBKDF2", + false, + ["deriveKey"] + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + // Fixed salt is acceptable here because the random IV per message + // provides semantic security; PBKDF2 still protects against offline + // brute-force of the shared password. + salt: new TextEncoder().encode("vencord-baseconv-v1"), + iterations: 100_000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + + keyCache.set(secret, key); + return key; +} + +// ─── Decoders ──────────────────────────────────────────────────────────────── + +function decodeBinary(text: string): string { + const parts = text.trim().split(/\s+/); + if (!parts.every(p => /^[01]{1,8}$/.test(p))) throw new Error("Invalid binary"); + return parts.map(b => String.fromCharCode(parseInt(b, 2))).join(""); +} + +function decodeOctal(text: string): string { + const parts = text.trim().split(/\s+/); + if (!parts.every(p => /^[0-7]+$/.test(p))) throw new Error("Invalid octal"); + return parts.map(o => String.fromCharCode(parseInt(o, 8))).join(""); +} + +function decodeDecimal(text: string): string { + const parts = text.trim().split(/\s+/); + if (!parts.every(p => /^\d+$/.test(p))) throw new Error("Invalid decimal"); + return parts.map(d => String.fromCharCode(Number(d))).join(""); +} + +function decodeHex(text: string): string { + const stripped = text.trim().replace(/0x/gi, ""); + const cleaned = /\s/.test(stripped) + ? stripped.split(/\s+/).map(s => s.padStart(2, "0")).join("") + : stripped; + if (!/^[0-9a-fA-F]+$/.test(cleaned) || cleaned.length % 2 !== 0) + throw new Error("Invalid hex"); + return cleaned.match(/.{2}/g)!.map(h => String.fromCharCode(parseInt(h, 16))).join(""); +} + +function decodeBase32(text: string): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const input = text.trim().toUpperCase().replace(/=+$/, ""); + if (!/^[A-Z2-7]*$/.test(input)) throw new Error("Invalid base32"); + let bits = 0, value = 0, output = ""; + for (const ch of input) { + const idx = alphabet.indexOf(ch); + if (idx === -1) throw new Error(`Invalid character: ${ch}`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + output += String.fromCharCode((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return output; +} + +function decodeBase64(text: string): string { + try { + return decodeURIComponent(escape(atob(text.trim()))); + } catch { + return atob(text.trim()); + } +} + +// Interpret space-separated hex pairs as raw UTF-8 bytes, then decode with +// TextDecoder. Unlike regular hex decode (which maps each byte to a Latin-1 +// code point), this correctly reassembles multi-byte Unicode sequences. +function decodeUtf8(text: string): string { + const stripped = text.trim().replace(/0x/gi, ""); + const cleaned = /\s/.test(stripped) + ? stripped.split(/\s+/).map(s => s.padStart(2, "0")).join("") + : stripped; + if (!/^[0-9a-fA-F]+$/.test(cleaned) || cleaned.length % 2 !== 0) + throw new Error("Invalid UTF-8 hex string"); + const bytes = new Uint8Array(cleaned.match(/.{2}/g)!.map(h => parseInt(h, 16))); + return new TextDecoder().decode(bytes); +} + +// Format: base64( iv[12 bytes] + ciphertext + AES-GCM auth tag[16 bytes] ) +async function decodeAes(text: string, secret: string): Promise { + if (!secret) throw new Error("AES secret is not set"); + + const raw = base64ToUint8(text.trim()); + if (raw.length < 28) throw new Error("Ciphertext too short"); + + const iv = raw.slice(0, 12); + const ciphertext = raw.slice(12); + const key = await getAesKey(secret); + + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext + ); + + return new TextDecoder().decode(plaintext); +} + +// ─── Encoders ──────────────────────────────────────────────────────────────── + +function encodeBinary(text: string): string { + return Array.from(text).map(c => c.charCodeAt(0).toString(2).padStart(8, "0")).join(" "); +} + +function encodeOctal(text: string): string { + return Array.from(text).map(c => c.charCodeAt(0).toString(8)).join(" "); +} + +function encodeDecimal(text: string): string { + return Array.from(text).map(c => String(c.charCodeAt(0))).join(" "); +} + +function encodeHex(text: string): string { + return Array.from(text).map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" "); +} + +function encodeBase32(text: string): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = 0, value = 0, output = ""; + for (let i = 0; i < text.length; i++) { + value = (value << 8) | text.charCodeAt(i); + bits += 8; + while (bits >= 5) { + output += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) output += alphabet[(value << (5 - bits)) & 31]; + while (output.length % 8 !== 0) output += "="; + return output; +} + +function encodeBase64(text: string): string { + try { + return btoa(unescape(encodeURIComponent(text))); + } catch { + return btoa(text); + } +} + +// Encode text as its raw UTF-8 bytes expressed as space-separated hex pairs. +// "Hello" → "48 65 6c 6c 6f" +// "é" → "c3 a9" (two bytes — distinct from Latin-1 hex encode) +function encodeUtf8(text: string): string { + const bytes = new TextEncoder().encode(text); + return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(" "); +} + +// Format: base64( iv[12 bytes] + ciphertext + AES-GCM auth tag[16 bytes] ) +// Pipeline: text → UTF-8 bytes → AES-256-GCM encrypt → base64 +async function encodeAes(text: string, secret: string): Promise { + if (!secret) throw new Error("AES secret is not set. Please add a shared secret in the plugin settings."); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await getAesKey(secret); + const plaintext = new TextEncoder().encode(text); + + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + plaintext + ); + + const combined = new Uint8Array(12 + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), 12); + + return uint8ToBase64(combined); +} + +// ─── Auto-Detection ─────────────────────────────────────────────────────────── +// AES output is intentionally indistinguishable from random base64, so we +// never attempt to auto-detect it — users must explicitly select "AES". + +export function autoDetectEncoding(text: string): Exclude | null { + const t = text.trim(); + + if (/^[01]{8}( [01]{8})+$/.test(t)) return "binary"; + + if (/^0x[0-9a-fA-F]{2}( 0x[0-9a-fA-F]{2})+$/.test(t)) return "hex"; + + if (/^[0-9a-fA-F]{2}( [0-9a-fA-F]{2})+$/.test(t) && /[a-fA-F]/.test(t)) return "hex"; + + if (/^[0-9a-fA-F]+$/.test(t) && t.length % 2 === 0 && t.length >= 4 && /[a-fA-F]/.test(t)) return "hex"; + + if (/^[A-Z2-7]+=*$/.test(t) && t.length >= 8 && t.length % 8 === 0) return "base32"; + + if (/^[A-Za-z0-9+/]+=*$/.test(t) && t.length >= 4 && t.length % 4 === 0 && /[a-z+/]/.test(t)) return "base64"; + + if (/^[0-7]{2,4}( [0-7]{2,4})+$/.test(t)) { + const codes = t.split(/\s+/).map(o => parseInt(o, 8)); + if (codes.every(n => n >= 32 && n <= 126)) return "octal"; + } + + if (/^\d+( \d+)+$/.test(t)) { + const codes = t.split(/\s+/).map(Number); + if (codes.length >= 3 && codes.every(n => n >= 32 && n <= 126)) return "decimal"; + } + + return null; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function decode( + text: string, + encoding: EncodingType, + aesSecret?: string +): Promise { + try { + let target: EncodeTarget; + + if (encoding === "auto") { + // AES output is valid base64, so try AES first when a secret is set + // to avoid misidentifying encrypted messages as regular base64. + if (aesSecret) { + try { + const aesResult = await decodeAes(text, aesSecret); + if (aesResult && aesResult.trim()) { + return { text: aesResult, encoding: ENCODING_LABELS["aes"] }; + } + } catch { + // AES failed — fall through to normal auto-detect + } + } + const detected = autoDetectEncoding(text); + if (!detected) return null; + target = detected; + } else { + target = encoding; + } + + let result: string; + switch (target) { + case "binary": result = decodeBinary(text); break; + case "octal": result = decodeOctal(text); break; + case "decimal": result = decodeDecimal(text); break; + case "hex": result = decodeHex(text); break; + case "base32": result = decodeBase32(text); break; + case "base64": result = decodeBase64(text); break; + case "utf8": result = decodeUtf8(text); break; + case "aes": result = await decodeAes(text, aesSecret ?? ""); break; + } + + if (!result || !result.trim()) return null; + + return { text: result, encoding: ENCODING_LABELS[target] }; + } catch { + return null; + } +} + +export async function encode( + text: string, + encoding: EncodeTarget, + aesSecret?: string +): Promise { + switch (encoding) { + case "binary": return encodeBinary(text); + case "octal": return encodeOctal(text); + case "decimal": return encodeDecimal(text); + case "hex": return encodeHex(text); + case "base32": return encodeBase32(text); + case "base64": return encodeBase64(text); + case "utf8": return encodeUtf8(text); + case "aes": return encodeAes(text, aesSecret ?? ""); + } +}