diff --git a/packages/core/jsr.json b/packages/core/jsr.json index f7c9cb3..34f6420 100644 --- a/packages/core/jsr.json +++ b/packages/core/jsr.json @@ -1,7 +1,7 @@ { "name": "@sinkr/core", "license": "MIT", - "version": "0.5.1", + "version": "0.6.0", "exports": { ".": "./src/index.ts", "./client": "./src/index.browser.ts", diff --git a/packages/core/package.json b/packages/core/package.json index e209931..07268fe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sinkr/core", - "version": "0.5.1", + "version": "0.6.0", "type": "module", "main": "src/index.ts", "exports": { @@ -26,7 +26,8 @@ }, "prettier": "@sinkr/prettier-config", "dependencies": { - "@sinkr/validators": "npm:@jsr/sinkr__validators@0.2.2", + "@oslojs/encoding": "^1.1.0", + "@sinkr/validators": "npm:@jsr/sinkr__validators@0.3.1", "emittery": "^1.0.3", "undici": "^7.1.0", "zod": "^3.24.1" diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index 8440538..885f094 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import { decodeBase64url } from "@oslojs/encoding"; import Emittery from "emittery"; import type { @@ -14,7 +15,7 @@ import type { import { ClientReceiveSchema } from "@sinkr/validators"; import type { RealEventMap } from "./event-fallback"; -import type { UserInfo } from "./types"; +import type { EncryptionInput, UserInfo } from "./types"; import { connectSymbol, countEventSymbol, @@ -42,7 +43,10 @@ interface DefaultEvents { } type GenericMessageEvent = Prettify< - Omit, "message"> & { message: T } + Omit, "message"> & { + message: T; + index?: number; + } >; type MappedEvents = { @@ -65,15 +69,124 @@ function proxyRemoveEmit>(emitter: T) { }); } +const SUPPORTED_HASHES = ["256", "384", "512"]; + +async function importRSAJWK(key: JsonWebKey) { + if (key.kty !== "RSA" || !key.alg) { + throw new Error("Invalid key type"); + } + const [_, algSubtype, hash] = key.alg.split("-"); + if (algSubtype !== "OAEP" || !hash) { + throw new Error("Invalid key type"); + } + if (!SUPPORTED_HASHES.includes(hash)) { + throw new Error("Unsupported hash"); + } + if (!key.key_ops?.includes("encrypt")) { + throw new Error("Key does not support encryption"); + } + return await crypto.subtle.importKey( + "jwk", + key, + { + name: "RSA-OAEP", + hash: `SHA-${hash}`, + }, + true, + key.key_ops as KeyUsage[], + ); +} + +async function importAESGCMJWK(key: JsonWebKey) { + if (key.kty !== "oct") { + throw new Error("Invalid key type"); + } + if (!key.key_ops?.includes("encrypt")) { + throw new Error("Key does not support encryption"); + } + if (key.alg !== "A256GCM") { + throw new Error("Unsupported algorithm"); + } + return await crypto.subtle.importKey( + "jwk", + key, + { + name: "AES-GCM", + }, + true, + key.key_ops as KeyUsage[], + ); +} + +async function importUnknownJWK(key: JsonWebKey | CryptoKey) { + if (key instanceof CryptoKey) { + if (!key.usages.includes("encrypt")) { + throw new Error("Key does not support encryption"); + } + switch (key.algorithm.name) { + case "RSA-OAEP": + case "AES-GCM": + return key; + } + throw new Error("Unsupported key type"); + } + if (!key.kty) { + throw new Error("Invalid key type"); + } + if (key.kty === "RSA") { + return await importRSAJWK(key); + } + if (key.kty === "oct") { + return await importAESGCMJWK(key); + } + throw new Error("Unsupported key type"); +} + +async function decrypt(ciphertext: string, key: CryptoKey) { + if (key.algorithm.name === "RSA-OAEP") { + const decoded = decodeBase64url(ciphertext); + return await crypto.subtle.decrypt( + { + name: "RSA-OAEP", + }, + key, + decoded, + ); + } else { + const [iv, msg] = ciphertext.split(".").map(decodeBase64url); + if (!iv || !msg) { + throw new Error("Invalid ciphertext"); + } + return await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + msg, + ); + } +} + class BrowserSinker extends Emittery { private ws: WebSocket | null = null; private channelCache = new Map>(); + private keyMap = new Map(); + constructor(private url: string) { super(); } + /** + * Imports a decryption key for the client to decrypt messages. + */ + async addDecryptionKey(key: EncryptionInput) { + const imported = await importUnknownJWK(key.key); + this.keyMap.set(key.keyId, imported); + } + /** * Get a sinkr channel by name. If a presence channel is specified, alternative types and messages will be available. * @param channel The channel to listen to. Events will only fire if the current client is subscribed to the channel. @@ -151,10 +264,7 @@ class BrowserSinker extends Emittery { break; } } else { - void this.emit( - data.data.event, - data.data as unknown as GenericMessageEvent, - ); + void this.emitMessage(data.data); } }); this.ws.addEventListener("close", () => { @@ -162,6 +272,44 @@ class BrowserSinker extends Emittery { this.ws = null; }); } + + private async emitMessage( + input: z.infer, + ) { + if ( + input.message.type === "encrypted" || + input.message.type === "encrypted-chunk" + ) { + const key = this.keyMap.get(input.message.keyId); + if (!key) { + return; + } + try { + const msg = await decrypt(input.message.ciphertext, key); + const msgDecoded = new TextDecoder().decode(msg); + const msgTransform = { + message: JSON.parse(msgDecoded) as T, + index: "index" in input.message ? input.message.index : undefined, + }; + await this.emit(input.event, { + ...input, + message: msgTransform, + }); + } catch (e) { + console.error(e); + return; + } + } else { + const msgTransform = { + message: input.message.message as T, + index: "index" in input.message ? input.message.index : undefined, + }; + await this.emit(input.event, { + ...input, + message: msgTransform, + }); + } + } } interface DefaultChannelEventMap { diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e866b75..809a4a6 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -1,11 +1,15 @@ import type { MessageEvent } from "undici"; import type { z } from "zod"; +import { encodeBase64url } from "@oslojs/encoding"; import { fetch, WebSocket } from "undici"; -import type { ServerEndpointSchema } from "@sinkr/validators"; +import type { + MessageTypeSchema, + ServerEndpointSchema, +} from "@sinkr/validators"; import type { RealEventMap } from "./event-fallback"; -import type { UserInfo } from "./types"; +import type { EncryptionInput, UserInfo } from "./types"; type SendDataParam = z.infer; @@ -82,18 +86,174 @@ function toReadableStream(input: ReadableInput): ReadableStream { return input; } -function prepareStream(shape: object, stream: ReadableStream) { +function prepareStream( + shape: object, + stream: ReadableStream, + key?: EncryptionInput, +) { + let index = 0; const transformer = new TransformStream({ - transform(chunk, controller) { + async transform(chunk, controller) { controller.enqueue({ ...shape, - message: chunk, + message: await getMessageContent(chunk, key, index), }); + index++; }, }); return stream.pipeThrough(transformer); } +const SUPPORTED_HASHES = ["256", "384", "512"]; + +async function importRSAJWK(key: JsonWebKey) { + if (key.kty !== "RSA" || !key.alg) { + throw new Error("Invalid key type"); + } + const [_, algSubtype, hash] = key.alg.split("-"); + if (algSubtype !== "OAEP" || !hash) { + throw new Error("Invalid key type"); + } + if (!SUPPORTED_HASHES.includes(hash)) { + throw new Error("Unsupported hash"); + } + if (!key.key_ops?.includes("encrypt")) { + throw new Error("Key does not support encryption"); + } + return await crypto.subtle.importKey( + "jwk", + key, + { + name: "RSA-OAEP", + hash: `SHA-${hash}`, + }, + true, + key.key_ops as KeyUsage[], + ); +} + +async function importAESGCMJWK(key: JsonWebKey) { + if (key.kty !== "oct") { + throw new Error("Invalid key type"); + } + if (!key.key_ops?.includes("encrypt")) { + throw new Error("Key does not support encryption"); + } + if (key.alg !== "A256GCM") { + throw new Error("Unsupported algorithm"); + } + return await crypto.subtle.importKey( + "jwk", + key, + { + name: "AES-GCM", + }, + true, + key.key_ops as KeyUsage[], + ); +} + +async function importUnknownJWK(key: JsonWebKey | CryptoKey) { + if (key instanceof CryptoKey) { + if (!key.usages.includes("encrypt")) { + throw new Error("Key does not support encryption"); + } + switch (key.algorithm.name) { + case "RSA-OAEP": + case "AES-GCM": + return key; + } + throw new Error("Unsupported key type"); + } + if (!key.kty) { + throw new Error("Invalid key type"); + } + if (key.kty === "RSA") { + return await importRSAJWK(key); + } + if (key.kty === "oct") { + return await importAESGCMJWK(key); + } + throw new Error("Unsupported key type"); +} + +async function encrypt(message: Uint8Array, key: CryptoKey) { + if (key.algorithm.name === "AES-GCM") { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { + name: key.algorithm.name, + iv, + }, + key, + message, + ); + const ivs = encodeBase64url(iv); + const es = encodeBase64url(new Uint8Array(encrypted)); + return `${ivs}.${es}`; + } + const encrypted = await crypto.subtle.encrypt( + { + name: key.algorithm.name, + }, + key, + message, + ); + return encodeBase64url(new Uint8Array(encrypted)); +} + +async function getMessageContent( + data: unknown, + keyData: EncryptionInput | undefined, + index?: number, +): Promise> { + if (!keyData) { + if (index !== undefined) { + return { + type: "chunk", + index, + message: data, + }; + } + return { + type: "plain", + message: data, + }; + } + try { + const imported = await importUnknownJWK(keyData.key); + const stringified = JSON.stringify(data); + const encoded = new TextEncoder().encode(stringified); + const ciphertext = await encrypt(encoded, imported); + if (index !== undefined) { + return { + type: "encrypted-chunk", + index, + ciphertext: ciphertext, + keyId: keyData.keyId, + }; + } + return { + type: "encrypted", + ciphertext: ciphertext, + keyId: keyData.keyId, + }; + } catch (e) { + console.error(e); + if (index !== undefined) { + return { + type: "chunk", + index, + message: data, + }; + } + return { + type: "plain", + message: data, + }; + } +} + class Sourcerer { private url: URL; private wsUrl: URL; @@ -134,16 +294,18 @@ class Sourcerer { data: TData, ): Promise; private async sendData( - data: TData, + data: TData extends { message: unknown } ? Omit : TData, iterable: ReadableInput, + key?: EncryptionInput, ): Promise; private async sendData( data: TData, iterable?: ReadableInput, + key?: EncryptionInput, ) { if (iterable) { const stream = toReadableStream(iterable); - const encodedStream = prepareStream(data, stream); + const encodedStream = prepareStream(data, stream, key); const ws = await this.connectWS(); const reader = encodedStream.getReader(); const statuses = new Map(); @@ -273,12 +435,17 @@ class Sourcerer { async sendToChannel< TEvent extends keyof RealEventMap, TData extends RealEventMap[TEvent], - >(channel: string, event: TEvent, message: TData): Promise { + >( + channel: string, + event: TEvent, + message: TData, + key?: EncryptionInput, + ): Promise { return await this.sendData({ route: "channel", channel, event: `${event}`, - message, + message: await getMessageContent(message, key), }); } @@ -296,6 +463,7 @@ class Sourcerer { channel: string, event: TEvent, data: ReadableInput, + key?: EncryptionInput, ): Promise { return await this.sendData( { @@ -304,6 +472,7 @@ class Sourcerer { event: `${event}`, }, data, + key, ); } @@ -317,12 +486,17 @@ class Sourcerer { async directMessage< TEvent extends keyof RealEventMap, TData extends RealEventMap[TEvent], - >(userId: string, event: TEvent, message: TData): Promise { + >( + userId: string, + event: TEvent, + message: TData, + key?: EncryptionInput, + ): Promise { return await this.sendData({ route: "direct", recipientId: userId, event: `${event}`, - message, + message: await getMessageContent(message, key), }); } @@ -340,6 +514,7 @@ class Sourcerer { userId: string, event: TEvent, data: ReadableInput, + key?: EncryptionInput, ): Promise { return await this.sendData( { @@ -348,6 +523,7 @@ class Sourcerer { event: `${event}`, }, data, + key, ); } @@ -360,11 +536,11 @@ class Sourcerer { async broadcastMessage< TEvent extends keyof RealEventMap, TData extends RealEventMap[TEvent], - >(event: TEvent, message: TData): Promise { + >(event: TEvent, message: TData, key?: EncryptionInput): Promise { return await this.sendData({ route: "broadcast", event: `${event}`, - message, + message: await getMessageContent(message, key), }); } @@ -377,13 +553,18 @@ class Sourcerer { async streamBroadcastMessage< TEvent extends keyof RealEventMap, TData extends RealEventMap[TEvent], - >(event: TEvent, data: ReadableInput): Promise { + >( + event: TEvent, + data: ReadableInput, + key?: EncryptionInput, + ): Promise { return await this.sendData( { route: "broadcast", event: `${event}`, }, data, + key, ); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 39fa203..af2589d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -47,3 +47,11 @@ export const connectSymbol: unique symbol = Symbol("sinkr-connect"); * Built-in event for when the client disconnects from Sinkr. */ export const disconnectSymbol: unique symbol = Symbol("sinkr-disconnect"); + +/** + * Type which represents an encryption key to be used to either encrypt or decrypt messages. + */ +export interface EncryptionInput { + keyId: string; + key: JsonWebKey | CryptoKey; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 455fe94..f0450d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,9 +241,12 @@ importers: packages/core: dependencies: + '@oslojs/encoding': + specifier: ^1.1.0 + version: 1.1.0 '@sinkr/validators': - specifier: npm:@jsr/sinkr__validators@0.2.2 - version: '@jsr/sinkr__validators@0.2.2' + specifier: npm:@jsr/sinkr__validators@0.3.1 + version: '@jsr/sinkr__validators@0.3.1' emittery: specifier: ^1.0.3 version: 1.0.3 @@ -1603,6 +1606,9 @@ packages: '@jsr/sinkr__validators@0.2.2': resolution: {integrity: sha512-H3WbKmoOQyWzaneaMt1xd3JOgD85cEsVBtqLQSxdWLHB8Rzfiqrd9q/LaUhc0DgRUkIYdoYw3Jz0P0H/S/lhbQ==, tarball: https://npm.jsr.io/~/11/@jsr/sinkr__validators/0.2.2.tgz} + '@jsr/sinkr__validators@0.3.1': + resolution: {integrity: sha512-3U7ul/AxRwQdXNVlaKAvh4vCkicUd/+CL8L2UeQ/+T5nI0GtTg5gc0Ho60AmnXTFMNNhuVQYCTwC2vUBLjYrXg==, tarball: https://npm.jsr.io/~/11/@jsr/sinkr__validators/0.3.1.tgz} + '@miniflare/core@2.14.4': resolution: {integrity: sha512-FMmZcC1f54YpF4pDWPtdQPIO8NXfgUxCoR9uyrhxKJdZu7M6n8QKopPVNuaxR40jcsdxb7yKoQoFWnHfzJD9GQ==} engines: {node: '>=16.13'} @@ -1740,6 +1746,9 @@ packages: rimraf: ^6.0.1 wrangler: ^3.99.0 + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -6489,6 +6498,10 @@ snapshots: dependencies: zod: 3.24.1 + '@jsr/sinkr__validators@0.3.1': + dependencies: + zod: 3.24.1 + '@miniflare/core@2.14.4': dependencies: '@iarna/toml': 2.2.5 @@ -6646,6 +6659,8 @@ snapshots: - aws-crt - supports-color + '@oslojs/encoding@1.1.0': {} + '@panva/hkdf@1.2.1': {} '@pkgjs/parseargs@0.11.0':