diff --git a/package.json b/package.json index b34f2cc9..c278e09e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@vladfrangu/async_event_emitter": "^2.4.7", "json-with-bigint": "^3.4.4", "solid-js": "^1.9.14", - "stoat-api": "0.15.3", + "stoat-api": "git+https://github.com/Pecacheu/javascript-client-api#8a612098dd853dbaaad6ae03baf2af40c079818c", "ulid": "^3.0.2" }, "devDependencies": { diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts index f7cc1d26..52b82760 100644 --- a/src/classes/Channel.ts +++ b/src/classes/Channel.ts @@ -1,4 +1,11 @@ -import { Accessor, Setter, batch, createSignal } from "solid-js"; +import { + Accessor, + Setter, + batch, + createEffect, + createSignal, + on, +} from "solid-js"; import { ReactiveMap } from "@solid-primitives/map"; import type { ReactiveSet } from "@solid-primitives/set"; @@ -19,6 +26,8 @@ import { decodeTime, ulid } from "ulid"; import { ChannelCollection } from "../collections/index.js"; import { UserSlowmodes } from "../events/v1.js"; import { hydrate } from "../hydration/index.js"; +import { APIMessageDec } from "../hydration/message.js"; +import { EncryptError, decryptStr, encryptStr } from "../lib/e2ee.js"; import { bitwiseAndEq, calculatePermission, @@ -27,7 +36,7 @@ import { Permission } from "../permissions/definitions.js"; import type { ChannelWebhook } from "./ChannelWebhook.js"; import type { File } from "./File.js"; -import type { Message } from "./Message.js"; +import { type Message, decodeMsg } from "./Message.js"; import type { Server } from "./Server.js"; import type { ServerMember } from "./ServerMember.js"; import type { User } from "./User.js"; @@ -60,6 +69,41 @@ export class Channel { const [slowmode, setSlowmode] = createSignal(); this.userSlowmode = slowmode; this.#setUserSlowmode = setSlowmode; + + //Decrypt messages if key changes + let decRun = false; + createEffect( + on( + () => this.key, + (key) => { + if (!key || decRun) return; + decRun = true; + (async () => { + const decoded: [Message, string][] = [], + id = this.id; + + //Decrypt asynchronously + for (const msg of this.#collection.client.messages.values()) + if (msg.channelId === id) + try { + const cont = msg._rawContent(); + if (cont != null) { + const dec = await decryptStr(key, cont); + if (dec != null) decoded.push([msg, dec]); + } + } catch (e) { + console.error(`Decrypt Msg ${this.id}`, e); + } + + //Run update in batch + batch(() => { + for (const [msg, dec] of decoded) msg._setDecoded(dec); + }); + decRun = false; + })(); + }, + ), + ); } /** @@ -267,6 +311,23 @@ export class Channel { return this.#collection.getUnderlyingObject(this.id).nsfw; } + /** + * Whether messages are end-to-end encrypted + */ + get e2e(): boolean { + return this.#collection.getUnderlyingObject(this.id).e2e; + } + + /** + * CryptoKey associated with this channel. Setting this enables decryption + */ + set key(k: CryptoKey) { + this.#collection.updateUnderlyingObject(this.id, "key", k); + } + get key(): CryptoKey | undefined { + return this.#collection.getUnderlyingObject(this.id).key; + } + /** * ID of the last message sent in this channel */ @@ -545,6 +606,15 @@ export class Channel { msg.flags |= 1; } + let decoded: string | undefined; + if (this.e2e) { + if (!this.key) throw new EncryptError("no_key"); + if (msg.content) { + decoded = msg.content; + msg.content = await encryptStr(this.key, msg.content); + } + } + const message = await this.#collection.client.api.post( `/channels/${this.id as ""}/messages`, msg, @@ -555,6 +625,12 @@ export class Channel { }, ); + //Mark as already decoded + if (decoded) { + (message as APIMessageDec)._dec = decoded; + delete message.content; + } + return this.#collection.client.messages.getOrCreate( message._id, message, @@ -573,6 +649,9 @@ export class Channel { `/channels/${this.id as ""}/messages/${messageId as ""}`, ); + const key = this.key; + if (key) await decodeMsg(key, message); + return this.#collection.client.messages.getOrCreate(message._id, message); } @@ -596,6 +675,9 @@ export class Channel { { ...params }, )) as APIMessage[]; + const key = this.key; + if (key) for (const m of messages) await decodeMsg(key, m); + return messages.map((message) => this.#collection.client.messages.getOrCreate(message._id, message), ); @@ -625,6 +707,9 @@ export class Channel { { ...params, include_users: true }, )) as { messages: APIMessage[]; users: APIUser[]; members?: APIMember[] }; + const key = this.key; + if (key) for (const m of data.messages) await decodeMsg(key, m); + return batch(() => ({ messages: data.messages.map((message) => this.#collection.client.messages.getOrCreate(message._id, message), diff --git a/src/classes/File.ts b/src/classes/File.ts index b0e37cb2..3fe43258 100644 --- a/src/classes/File.ts +++ b/src/classes/File.ts @@ -1,6 +1,9 @@ +import { createSignal } from "solid-js"; + import type { File as APIFile, Metadata } from "stoat-api"; import type { Client } from "../Client.js"; +import { decrypt } from "../lib/e2ee.js"; /** * Uploaded File @@ -38,6 +41,12 @@ export class File { */ readonly size?: number; + /** Key's channel id, if encrypted */ + readonly e2e_id: string; + + #url; + #setUrl; + /** * Construct File * @param client Client @@ -48,38 +57,77 @@ export class File { file: (Pick & Partial) | File, ) { this.#client = client; - if (file instanceof File) { + + if ("id" in file) { this.id = file.id; - this.tag = file.tag; - this.filename = file.filename; - this.metadata = file.metadata; this.contentType = file.contentType; - this.size = file.size; } else { this.id = file._id; - this.tag = file.tag; - this.filename = file.filename; - this.metadata = file.metadata; this.contentType = file.content_type; - this.size = file.size; + } + + this.tag = file.tag; + this.filename = file.filename; + this.metadata = file.metadata; + this.size = file.size; + this.e2e_id = file.e2e_id!; + + if (this.e2e_id && !("id" in file)) { + const [url, setUrl] = createSignal(); + this.#url = url; + this.#setUrl = setUrl; + + if (this.filename) { + const extIdx = this.filename.indexOf("."), + ext = extIdx === -1 ? "" : this.filename.slice(extIdx), + mime = this.filename.slice(0, -ext.length); + + //Detect meta type from mime + (this.contentType as string) = mime; + if (mime.startsWith("image/")) this.metadata.type = "Image"; + else if (mime.startsWith("video/")) this.metadata.type = "Video"; + else if (mime.startsWith("audio/")) this.metadata.type = "Audio"; + else if (mime.startsWith("text/")) this.metadata.type = "Text"; + this.filename = this.metadata.type.toLowerCase() + ext; + } } } - /** - * Direct URL to the file - */ - // get url(): string { - // if (!this.filename) return this.previewUrl; - // - // return `${this.#client.configuration?.features.autumn.url}/${this.tag}/${ - // this.id - // }/${this.filename}`; - // } + /** Load encrypted file URL, if any. Call `unloadFile()` when finished */ + async loadFile() { + if (!this.e2e_id) return; + const key = this.#client.channels.get(this.e2e_id)?.key; + if (key) + try { + const req = await fetch(this._rawUrl); + if (req.status !== 200) throw `HTTP Code ${req.status}`; + const buf = await decrypt(key, await req.arrayBuffer()); + this.#setUrl!( + URL.createObjectURL(new Blob([buf], { type: this.contentType })), + ); + } catch (e) { + console.error(`Decrypt File ${this.filename}`, e); + } + } + + /** Unload file from memory */ + unloadFile() { + if (!this.#url) return; + URL.revokeObjectURL(this.#url!()!); + this.#setUrl!(); + } + + get _rawUrl() { + return `${this.#client.configuration?.features.autumn.url}/${ + this.tag + }/${this.id}/original`; + } /** * Preview URL for the file */ get previewUrl(): string { + if (this.e2e_id) return this.#url!() ?? ""; return `${this.#client.configuration?.features.autumn.url}/${ this.tag }/${this.id}`; @@ -89,9 +137,7 @@ export class File { * Original download URL for the file */ get originalUrl(): string { - return `${this.#client.configuration?.features.autumn.url}/${ - this.tag - }/${this.id}/original`; + return this.e2e_id ? (this.#url!() ?? "") : this._rawUrl; } /** @@ -122,6 +168,8 @@ export class File { * @returns Generated URL or nothing */ createFileURL(forceAnimation?: boolean): string | undefined { + if (this.e2e_id) return this.#url!() ?? ""; + const autumn = this.#client.configuration?.features.autumn; if (!autumn?.enabled) return; diff --git a/src/classes/Message.ts b/src/classes/Message.ts index 44e4b077..896c7ce1 100644 --- a/src/classes/Message.ts +++ b/src/classes/Message.ts @@ -11,7 +11,8 @@ import { decodeTime } from "ulid"; import type { Client } from "../Client.js"; import type { MessageCollection } from "../collections/MessageCollection.js"; -import { MessageFlags } from "../hydration/message.js"; +import { APIMessageDec, MessageFlags } from "../hydration/message.js"; +import { EncryptError, decryptStr, encryptStr } from "../lib/e2ee.js"; import type { Channel } from "./Channel.js"; import { File } from "./File.js"; @@ -22,6 +23,20 @@ import { ServerRole } from "./ServerRole.js"; import type { SystemMessage } from "./SystemMessage.js"; import type { User } from "./User.js"; +/** Load decrypted payload into APIMessage */ +export async function decodeMsg( + cliOrKey: Client | CryptoKey, + msg: Partial, +) { + if (!msg.content) return; + const key = + "usages" in cliOrKey ? cliOrKey : cliOrKey.channels.get(msg.channel!)?.key; + if (key) { + msg._dec = await decryptStr(key, msg.content!); + delete msg.content; + } +} + /** * Message Class */ @@ -39,6 +54,15 @@ export class Message { this.id = id; } + _rawContent() { + return this.#collection.getUnderlyingObject(this.id).content; + } + + _setDecoded(dec: string) { + this.#collection.updateUnderlyingObject(this.id, "_dec", dec); + this.#collection.updateUnderlyingObject(this.id, "content", undefined); + } + /** * Whether this object exists */ @@ -134,7 +158,8 @@ export class Message { * Content */ get content(): string { - return this.#collection.getUnderlyingObject(this.id).content ?? ""; + const obj = this.#collection.getUnderlyingObject(this.id); + return (this.channel?.e2e ? obj._dec : obj.content) ?? ""; } /** @@ -330,6 +355,12 @@ export class Message { * @param data Message edit route data */ async edit(data: DataEditMessage): Promise { + const chan = this.channel; + if (chan?.e2e) { + if (!chan.key) throw new EncryptError("no_key"); + if (data.content) data.content = await encryptStr(chan.key, data.content); + } + return await this.#collection.client.api.patch( `/channels/${this.channelId as ""}/messages/${this.id as ""}`, data, diff --git a/src/collections/MessageCollection.ts b/src/collections/MessageCollection.ts index 55210113..8b10fe04 100644 --- a/src/collections/MessageCollection.ts +++ b/src/collections/MessageCollection.ts @@ -1,6 +1,6 @@ import type { Message as APIMessage } from "stoat-api"; -import { Message } from "../classes/Message.js"; +import { Message, decodeMsg } from "../classes/Message.js"; import type { HydratedMessage } from "../hydration/message.js"; import { ClassCollection } from "./Collection.js"; @@ -26,6 +26,7 @@ export class MessageCollection extends ClassCollection< `/channels/${channelId as ""}/messages/${messageId as ""}`, ); + await decodeMsg(this.client, data); return this.getOrCreate(data._id, data, false); } diff --git a/src/events/v1.ts b/src/events/v1.ts index 868a472f..6bbe2da0 100644 --- a/src/events/v1.ts +++ b/src/events/v1.ts @@ -24,6 +24,7 @@ import type { Client } from "../Client.js"; import { MessageEmbed } from "../classes/MessageEmbed.js"; import { ServerRole } from "../classes/ServerRole.js"; import { VoiceParticipant } from "../classes/VoiceParticipant.js"; +import { decodeMsg } from "../classes/index.js"; import { hydrate } from "../hydration/index.js"; /** @@ -358,6 +359,7 @@ export async function handleEvent( } case "Message": { if (!client.messages.has(event._id)) { + await decodeMsg(client, event); batch(() => { if (event.member) { client.serverMembers.getOrCreate(event.member._id, event.member); @@ -405,13 +407,11 @@ export async function handleEvent( channelId: event.channel, }; + event.data.channel = event.channel; + await decodeMsg(client, event.data); + client.messages.updateUnderlyingObject(event.id, { - ...hydrate( - "message", - { ...event.data, channel: event.channel }, - client, - false, - ), + ...hydrate("message", event.data, client, false), editedAt: new Date(), }); diff --git a/src/hydration/channel.ts b/src/hydration/channel.ts index f29a7ccc..bbb26af3 100644 --- a/src/hydration/channel.ts +++ b/src/hydration/channel.ts @@ -28,10 +28,11 @@ export type HydratedChannel = { rolePermissions?: Record; nsfw: boolean; slowmode: number; - lastMessageId?: string; - voice?: { maxUsers?: number }; + + e2e: boolean; + key?: CryptoKey; }; export const channelHydration: Hydrate, HydratedChannel> = { @@ -46,6 +47,7 @@ export const channelHydration: Hydrate, HydratedChannel> = { role_permissions: "rolePermissions", last_message_id: "lastMessageId", slowmode: "slowmode", + e2e: "e2e", }, functions: { id: (channel) => channel._id, @@ -75,6 +77,8 @@ export const channelHydration: Hydrate, HydratedChannel> = { ]), ), nsfw: (channel) => channel.nsfw || false, + e2e: (channel) => channel.e2e || false, + key: () => undefined, lastMessageId: (channel) => channel.last_message_id!, slowmode: (channel) => channel.slowmode ?? 0, voice: (channel) => diff --git a/src/hydration/message.ts b/src/hydration/message.ts index b1ef8d4e..7551fc1f 100644 --- a/src/hydration/message.ts +++ b/src/hydration/message.ts @@ -18,6 +18,7 @@ export type HydratedMessage = { authorId?: string; webhook?: MessageWebhook; content?: string; + _dec?: string; systemMessage?: SystemMessage; attachments?: File[]; editedAt?: Date; @@ -32,6 +33,8 @@ export type HydratedMessage = { flags?: MessageFlags; }; +export type APIMessageDec = Message & { _dec?: string }; + export const messageHydration: Hydrate, HydratedMessage> = { keyMapping: { _id: "id", @@ -53,6 +56,7 @@ export const messageHydration: Hydrate, HydratedMessage> = { ? new MessageWebhook(ctx as Client, message.webhook, message.author) : undefined, content: (message) => message.content!, + _dec: (message: APIMessageDec) => message._dec, systemMessage: (message, ctx) => SystemMessage.from(ctx as Client, message, message.system!), attachments: (message, ctx) => diff --git a/src/index.ts b/src/index.ts index ab0bdb67..726b43dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,4 +9,5 @@ export { BotFlags } from "./hydration/bot.js"; export { ServerFlags } from "./hydration/server.js"; export { UserBadges, UserFlags } from "./hydration/user.js"; export * from "./lib/regex.js"; +export * from "./lib/e2ee.js"; export * from "./permissions/definitions.js"; diff --git a/src/lib/e2ee.ts b/src/lib/e2ee.ts new file mode 100644 index 00000000..bf351762 --- /dev/null +++ b/src/lib/e2ee.ts @@ -0,0 +1,142 @@ +/** Cipher used for message encryption */ +export const Cipher = "AES-GCM"; + +const NonceLen = 12; +const MsgEncoder = new TextEncoder(); +const MsgDecoder = new TextDecoder(); +const hasTransfer = "transfer" in ArrayBuffer.prototype; + +type EncErrorType = "bad_version" | "no_key"; + +export class EncryptError extends Error { + type: "EncryptError"; + name: EncErrorType; + + constructor(name: EncErrorType) { + super(); + this.type = "EncryptError"; + this.name = name; + } +} + +declare global { + export interface Uint8ArrayConstructor { + fromBase64: (s: string) => Uint8Array; + } + export interface Uint8Array { + toBase64: ( + o?: Partial<{ alphabet: string; omitPadding: boolean }>, + ) => string; + } + export interface ArrayBuffer { + transfer: (l: number) => ArrayBuffer; + } +} + +/** Generate message nonce for AES encryption */ +function genNonce() { + const iv = new Uint8Array(NonceLen); + //Protocol version (1 byte) + iv[0] = 1; + //Timestamp to ensure uniqueness (6 bytes) + const ivDate = new BigInt64Array([BigInt(Date.now())]); + iv.set(new Uint8Array(ivDate.buffer), 1); + //Random entropy (5 bytes) + crypto.getRandomValues(iv.subarray(7)); + return iv; +} + +/** Encrypt buffer and return in Stoat encryption format */ +export async function encrypt(key: CryptoKey, data: BufferSource) { + const iv = genNonce(), + buf = await crypto.subtle.encrypt({ name: Cipher, iv }, key, data); + + const nBuf = new Uint8Array( + hasTransfer + ? iv.buffer.transfer(NonceLen + buf.byteLength) + : ((NonceLen + buf.byteLength) as never), + ); + if (!hasTransfer) nBuf.set(iv); + nBuf.set(new Uint8Array(buf), NonceLen); + return nBuf; +} + +/** Encrypt text string and return base64 */ +export const encryptStr = async (key: CryptoKey, str: string) => + new Uint8Array(await encrypt(key, MsgEncoder.encode(str))).toBase64({ + omitPadding: true, + }); + +/** Decrypt buffer from Stoat encryption format */ +export async function decrypt(key: CryptoKey, data: ArrayBuffer) { + const iv = new Uint8Array(data, 0, NonceLen); + if (iv[0] !== 1) throw new EncryptError("bad_version"); + return crypto.subtle.decrypt( + { name: Cipher, iv }, + key, + new Uint8Array(data, NonceLen), + ); +} + +/** Decrypt text string from base64 */ +export async function decryptStr(key?: CryptoKey, base64?: string) { + if (!key || !base64) return base64; + try { + return MsgDecoder.decode( + await decrypt(key, Uint8Array.fromBase64(base64).buffer), + ); + } catch (e) { + console.error(e); + return "`[ Decryption Error ]`"; + } +} + +//TODO Annoyingly, these are not exported from the ULID library. Should submit a PR to them +const B32_CHARACTERS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +function crockfordDecode(input: string) { + input = input.toUpperCase().split("").reverse().join(""); + const output = []; + let bitsRead = 0, + buffer = 0; + for (const c of input) { + const byte = B32_CHARACTERS.indexOf(c); + if (byte === -1) + throw new Error(`Invalid base 32 character found in string: ${c}`); + buffer |= byte << bitsRead; + bitsRead += 5; + while (bitsRead >= 8) { + output.unshift(buffer & 0xff); + buffer >>>= 8; + bitsRead -= 8; + } + } + if (bitsRead >= 5 || buffer > 0) output.unshift(buffer & 0xff); + return new Uint8Array(output); +} + +/** Generate CryptoKey. Channel ID is used for salt */ +export async function genKey( + channelId: string, + pwd: string, +): Promise { + const base = await crypto.subtle.importKey( + "raw", + MsgEncoder.encode(pwd), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: crockfordDecode(channelId), + iterations: 600000, //TODO Is this high enough? + hash: "SHA-256", + }, + base, + { name: Cipher, length: 256 }, + false, + ["encrypt", "decrypt"], + ); +}