diff --git a/packages/voice/__tests__/VoiceConnection.test.ts b/packages/voice/__tests__/VoiceConnection.test.ts index f39f67a3a..4c0e82246 100644 --- a/packages/voice/__tests__/VoiceConnection.test.ts +++ b/packages/voice/__tests__/VoiceConnection.test.ts @@ -344,7 +344,11 @@ describe('VoiceConnection#configureNetworking', () => { sessionId: state.session_id, userId: state.user_id, }, - false, + { + daveEncryption: true, + debug: false, + decryptionFailureTolerance: undefined, + }, ); expect(voiceConnection.state).toMatchObject({ status: VoiceConnectionStatus.Connecting, diff --git a/packages/voice/__tests__/VoiceReceiver.test.ts b/packages/voice/__tests__/VoiceReceiver.test.ts index c08700519..7410ce2b2 100644 --- a/packages/voice/__tests__/VoiceReceiver.test.ts +++ b/packages/voice/__tests__/VoiceReceiver.test.ts @@ -4,7 +4,7 @@ import { Buffer } from 'node:buffer'; import { once } from 'node:events'; import process from 'node:process'; -import { VoiceOpcodes } from 'discord-api-types/voice/v4'; +import { VoiceOpcodes } from 'discord-api-types/voice/v8'; import { describe, test, expect, vitest, beforeEach } from 'vitest'; import { RTP_PACKET_DESKTOP, @@ -141,36 +141,6 @@ describe('VoiceReceiver', () => { userId: '123abc', }); }); - - test('CLIENT_CONNECT packet', () => { - const spy = vitest.spyOn(receiver.ssrcMap, 'update'); - receiver['onWsPacket']({ - op: VoiceOpcodes.ClientConnect, - d: { - audio_ssrc: 123, - video_ssrc: 43, - user_id: '123abc', - }, - }); - expect(spy).toHaveBeenCalledWith({ - audioSSRC: 123, - videoSSRC: 43, - userId: '123abc', - }); - receiver['onWsPacket']({ - op: VoiceOpcodes.ClientConnect, - d: { - audio_ssrc: 123, - video_ssrc: 0, - user_id: '123abc', - }, - }); - expect(spy).toHaveBeenCalledWith({ - audioSSRC: 123, - videoSSRC: undefined, - userId: '123abc', - }); - }); }); describe('decrypt', () => { diff --git a/packages/voice/__tests__/VoiceWebSocket.test.ts b/packages/voice/__tests__/VoiceWebSocket.test.ts index 87fc72ecb..04fe58f6b 100644 --- a/packages/voice/__tests__/VoiceWebSocket.test.ts +++ b/packages/voice/__tests__/VoiceWebSocket.test.ts @@ -1,5 +1,5 @@ import { type EventEmitter, once } from 'node:events'; -import { VoiceOpcodes } from 'discord-api-types/voice/v4'; +import { VoiceOpcodes } from 'discord-api-types/voice/v8'; import { describe, test, expect, beforeEach } from 'vitest'; import WS from 'vitest-websocket-mock'; import { VoiceWebSocket } from '../src/networking/VoiceWebSocket'; diff --git a/packages/voice/package.json b/packages/voice/package.json index 2642f64e7..948d9b4c3 100644 --- a/packages/voice/package.json +++ b/packages/voice/package.json @@ -75,6 +75,7 @@ "@discordjs/scripts": "workspace:^", "@favware/cliff-jumper": "^4.1.0", "@noble/ciphers": "^1.2.1", + "@snazzah/davey": "^0.1.6", "@types/node": "^22.15.2", "@vitest/coverage-v8": "^3.1.1", "cross-env": "^7.0.3", diff --git a/packages/voice/src/VoiceConnection.ts b/packages/voice/src/VoiceConnection.ts index da64e900c..33f275dfc 100644 --- a/packages/voice/src/VoiceConnection.ts +++ b/packages/voice/src/VoiceConnection.ts @@ -182,6 +182,12 @@ export interface VoiceConnection extends EventEmitter { * @eventProperty */ on(event: 'stateChange', listener: (oldState: VoiceConnectionState, newState: VoiceConnectionState) => void): this; + /** + * Emitted when the end-to-end encrypted session has transitioned + * + * @eventProperty + */ + on(event: 'transitioned', listener: (transitionId: number) => void): this; /** * Emitted when the state of the voice connection changes to a specific status * @@ -235,6 +241,11 @@ export class VoiceConnection extends EventEmitter { */ private readonly debug: ((message: string) => void) | null; + /** + * The options used to create this voice connection. + */ + private readonly options: CreateVoiceConnectionOptions; + /** * Creates a new voice connection. * @@ -253,6 +264,7 @@ export class VoiceConnection extends EventEmitter { this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this); this.onNetworkingError = this.onNetworkingError.bind(this); this.onNetworkingDebug = this.onNetworkingDebug.bind(this); + this.onNetworkingTransitioned = this.onNetworkingTransitioned.bind(this); const adapter = options.adapterCreator({ onVoiceServerUpdate: (data) => this.addServerPacket(data), @@ -268,6 +280,7 @@ export class VoiceConnection extends EventEmitter { }; this.joinConfig = joinConfig; + this.options = options; } /** @@ -295,6 +308,7 @@ export class VoiceConnection extends EventEmitter { oldNetworking.off('error', this.onNetworkingError); oldNetworking.off('close', this.onNetworkingClose); oldNetworking.off('stateChange', this.onNetworkingStateChange); + oldNetworking.off('transitioned', this.onNetworkingTransitioned); oldNetworking.destroy(); } @@ -412,14 +426,20 @@ export class VoiceConnection extends EventEmitter { token: server.token, sessionId: state.session_id, userId: state.user_id, + channelId: state.channel_id!, + }, + { + debug: Boolean(this.debug), + daveEncryption: this.options.daveEncryption ?? true, + decryptionFailureTolerance: this.options.decryptionFailureTolerance, }, - Boolean(this.debug), ); networking.once('close', this.onNetworkingClose); networking.on('stateChange', this.onNetworkingStateChange); networking.on('error', this.onNetworkingError); networking.on('debug', this.onNetworkingDebug); + networking.on('transitioned', this.onNetworkingTransitioned); this.state = { ...this.state, @@ -509,6 +529,15 @@ export class VoiceConnection extends EventEmitter { this.debug?.(`[NW] ${message}`); } + /** + * Propagates transitions from the underlying network instance. + * + * @param transitionId - The transition id + */ + private onNetworkingTransitioned(transitionId: number) { + this.emit('transitioned', transitionId); + } + /** * Prepares an audio packet for dispatch. * @@ -694,6 +723,41 @@ export class VoiceConnection extends EventEmitter { }; } + /** + * The current voice privacy code of the encrypted session. + * + * @remarks + * For this data to be available, the VoiceConnection must be in the Ready state, + * and the connection would have to be end-to-end encrypted. + */ + public get voicePrivacyCode() { + if ( + this.state.status === VoiceConnectionStatus.Ready && + this.state.networking.state.code === NetworkingStatusCode.Ready + ) { + return this.state.networking.state.dave?.voicePrivacyCode ?? undefined; + } + + return undefined; + } + + /** + * Gets the verification code for a user in the session. + * + * @throws Will throw if end-to-end encryption is not on or if the user id provided is not in the session. + */ + public async getVerificationCode(userId: string): Promise { + if ( + this.state.status === VoiceConnectionStatus.Ready && + this.state.networking.state.code === NetworkingStatusCode.Ready && + this.state.networking.state.dave + ) { + return this.state.networking.state.dave.getVerificationCode(userId); + } + + throw new Error('Session not available'); + } + /** * Called when a subscription of this voice connection to an audio player is removed. * diff --git a/packages/voice/src/index.ts b/packages/voice/src/index.ts index d11869512..f14341308 100644 --- a/packages/voice/src/index.ts +++ b/packages/voice/src/index.ts @@ -19,6 +19,7 @@ export { VoiceUDPSocket, VoiceWebSocket, type SocketConfig, + DAVESession, } from './networking/index.js'; export { diff --git a/packages/voice/src/joinVoiceChannel.ts b/packages/voice/src/joinVoiceChannel.ts index 15c083c75..0b3a98db5 100644 --- a/packages/voice/src/joinVoiceChannel.ts +++ b/packages/voice/src/joinVoiceChannel.ts @@ -8,11 +8,22 @@ import type { DiscordGatewayAdapterCreator } from './util/adapter'; export interface CreateVoiceConnectionOptions { adapterCreator: DiscordGatewayAdapterCreator; + /** + * Whether to use the DAVE protocol for end-to-end encryption. Defaults to true. + */ + daveEncryption?: boolean | undefined; + /** * If true, debug messages will be enabled for the voice connection and its * related components. Defaults to false. */ debug?: boolean | undefined; + + /** + * The amount of consecutive decryption failures needed to try to + * re-initialize the end-to-end encrypted session to recover. Defaults to 24. + */ + decryptionFailureTolerance?: number | undefined; } /** @@ -61,5 +72,7 @@ export function joinVoiceChannel(options: CreateVoiceConnectionOptions & JoinVoi return createVoiceConnection(joinConfig, { adapterCreator: options.adapterCreator, debug: options.debug, + daveEncryption: options.daveEncryption, + decryptionFailureTolerance: options.decryptionFailureTolerance, }); } diff --git a/packages/voice/src/networking/DAVESession.ts b/packages/voice/src/networking/DAVESession.ts new file mode 100644 index 000000000..0e3a3de65 --- /dev/null +++ b/packages/voice/src/networking/DAVESession.ts @@ -0,0 +1,423 @@ +import { Buffer } from 'node:buffer'; +import { EventEmitter } from 'node:events'; +import type { VoiceDavePrepareEpochData, VoiceDavePrepareTransitionData } from 'discord-api-types/voice/v8'; +import { SILENCE_FRAME } from '../audio/AudioPlayer'; + +interface SessionMethods { + canPassthrough(userId: string): boolean; + decrypt(userId: string, mediaType: 0 | 1, packet: Buffer): Buffer; + encryptOpus(packet: Buffer): Buffer; + getSerializedKeyPackage(): Buffer; + getVerificationCode(userId: string): Promise; + processCommit(commit: Buffer): void; + processProposals(optype: 0 | 1, proposals: Buffer, recognizedUserIds?: string[]): ProposalsResult; + processWelcome(welcome: Buffer): void; + ready: boolean; + reinit(protocolVersion: number, userId: string, channelId: string): void; + reset(): void; + setExternalSender(externalSender: Buffer): void; + setPassthroughMode(passthrough: boolean, expiry: number): void; + voicePrivacyCode: string; +} + +interface ProposalsResult { + commit?: Buffer; + welcome?: Buffer; +} + +let Davey: any = null; + +/** + * The amount of seconds that a previous transition should be valid for. + */ +const TRANSITION_EXPIRY = 10; + +/** + * The arbitrary amount of seconds to allow passthrough for mid-downgrade. + * Generally, transitions last about 3 seconds maximum, but this should cover for when connections are delayed. + */ +const TRANSITION_EXPIRY_PENDING_DOWNGRADE = 24; + +/** + * The amount of packets to allow decryption failure for until we deem the transition bad and re-initialize. + * Usually 4 packets on a good connection may slip past when entering a new session. + * After re-initializing, 5-24 packets may fail to decrypt after. + */ +export const DEFAULT_DECRYPTION_FAILURE_TOLERANCE = 36; + +// eslint-disable-next-line no-async-promise-executor +export const daveLoadPromise = new Promise(async (resolve) => { + try { + const lib = await import('@snazzah/davey'); + Davey = lib; + } catch {} + + resolve(); +}); + +interface TransitionResult { + success: boolean; + transitionId: number; +} +/** + * Options that dictate the session behavior. + */ +export interface DAVESessionOptions { + decryptionFailureTolerance?: number | undefined; +} + +/** + * The maximum DAVE protocol version supported. + */ +export function getMaxProtocolVersion(): number | null { + return Davey?.DAVE_PROTOCOL_VERSION; +} + +export interface DAVESession extends EventEmitter { + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'debug', listener: (message: string) => void): this; + on(event: 'keyPackage', listener: (message: Buffer) => void): this; + on(event: 'invalidateTransition', listener: (transitionId: number) => void): this; +} + +/** + * Manages the DAVE protocol group session. + */ +export class DAVESession extends EventEmitter { + /** + * The channel id represented by this session. + */ + public channelId: string; + + /** + * The user id represented by this session. + */ + public userId: string; + + /** + * The protocol version being used. + */ + public protocolVersion: number; + + /** + * The last transition id executed. + */ + public lastTransitionId?: number | undefined; + + /** + * The pending transition. + */ + private pendingTransition?: VoiceDavePrepareTransitionData | undefined; + + /** + * Whether this session was downgraded previously. + */ + private downgraded = false; + + /** + * The amount of consecutive failures encountered when decrypting. + */ + private consecutiveFailures = 0; + + /** + * The amount of consecutive failures needed to attempt to recover. + */ + private readonly failureTolerance: number; + + /** + * Whether this session is currently re-initializing due to an invalid transition. + */ + public reinitializing = false; + + /** + * The underlying DAVE Session of this wrapper. + */ + public session: SessionMethods | undefined; + + public constructor(protocolVersion: number, userId: string, channelId: string, options: DAVESessionOptions) { + if (Davey === null) + throw new Error( + `Cannot utilize the DAVE protocol as the @snazzah/davey package has not been installed. +- Use the generateDependencyReport() function for more information.\n`, + ); + + super(); + + this.protocolVersion = protocolVersion; + this.userId = userId; + this.channelId = channelId; + this.failureTolerance = options.decryptionFailureTolerance ?? DEFAULT_DECRYPTION_FAILURE_TOLERANCE; + } + + /** + * The current voice privacy code of the session. Will be `null` if there is no session. + */ + public get voicePrivacyCode(): string | null { + if (this.protocolVersion === 0 || !this.session?.voicePrivacyCode) { + return null; + } + + return this.session.voicePrivacyCode; + } + + /** + * Gets the verification code for a user in the session. + * + * @throws Will throw if there is not an active session or the user id provided is invalid or not in the session. + */ + public async getVerificationCode(userId: string): Promise { + if (!this.session) throw new Error('Session not available'); + return this.session.getVerificationCode(userId); + } + + /** + * Re-initializes (or initializes) the underlying session. + */ + public reinit() { + if (this.protocolVersion > 0) { + if (this.session) { + this.session.reinit(this.protocolVersion, this.userId, this.channelId); + this.emit('debug', `Session reinitialized for protocol version ${this.protocolVersion}`); + } else { + this.session = new Davey.DAVESession(this.protocolVersion, this.userId, this.channelId); + this.emit('debug', `Session initialized for protocol version ${this.protocolVersion}`); + } + + this.emit('keyPackage', this.session!.getSerializedKeyPackage()); + } else if (this.session) { + this.session.reset(); + this.session.setPassthroughMode(true, TRANSITION_EXPIRY); + this.emit('debug', 'Session reset'); + } + } + + /** + * Set the external sender for this session. + * + * @param externalSender - The external sender + */ + public setExternalSender(externalSender: Buffer) { + if (!this.session) throw new Error('No session available'); + this.session.setExternalSender(externalSender); + this.emit('debug', 'Set MLS external sender'); + } + + /** + * Prepare for a transition. + * + * @param data - The transition data + * @returns Whether we should signal to the voice server that we are ready + */ + public prepareTransition(data: VoiceDavePrepareTransitionData) { + this.emit('debug', `Preparing for transition (${data.transition_id}, v${data.protocol_version})`); + this.pendingTransition = data; + + // When the included transition id is 0, the transition is for (re)initialization and it can be executed immediately. + if (data.transition_id === 0) { + this.executeTransition(data.transition_id); + } else { + if (data.protocol_version === 0) this.session?.setPassthroughMode(true, TRANSITION_EXPIRY_PENDING_DOWNGRADE); + return true; + } + + return false; + } + + /** + * Execute a transition. + * + * @param transitionId - The transition id to execute on + */ + public executeTransition(transitionId: number) { + this.emit('debug', `Executing transition (${transitionId})`); + if (!this.pendingTransition) { + this.emit('debug', `Received execute transition, but we don't have a pending transition for ${transitionId}`); + return; + } + + let transitioned = false; + if (transitionId === this.pendingTransition.transition_id) { + const oldVersion = this.protocolVersion; + this.protocolVersion = this.pendingTransition.protocol_version; + + // Handle upgrades & defer downgrades + if (oldVersion !== this.protocolVersion && this.protocolVersion === 0) { + this.downgraded = true; + this.emit('debug', 'Session downgraded'); + } else if (transitionId > 0 && this.downgraded) { + this.downgraded = false; + this.session?.setPassthroughMode(true, TRANSITION_EXPIRY); + this.emit('debug', 'Session upgraded'); + } + + // In the future we'd want to signal to the DAVESession to transition also, but it only supports v1 at this time + transitioned = true; + this.reinitializing = false; + this.lastTransitionId = transitionId; + this.emit('debug', `Transition executed (v${oldVersion} -> v${this.protocolVersion}, id: ${transitionId})`); + } else { + this.emit( + 'debug', + `Received execute transition for an unexpected transition id (expected: ${this.pendingTransition.transition_id}, actual: ${transitionId})`, + ); + } + + this.pendingTransition = undefined; + return transitioned; + } + + /** + * Prepare for a new epoch. + * + * @param data - The epoch data + */ + public prepareEpoch(data: VoiceDavePrepareEpochData) { + this.emit('debug', `Preparing for epoch (${data.epoch})`); + if (data.epoch === 1) { + this.protocolVersion = data.protocol_version; + this.reinit(); + } + } + + /** + * Recover from an invalid transition by re-initializing. + * + * @param transitionId - The transition id to invalidate + */ + public recoverFromInvalidTransition(transitionId: number) { + if (this.reinitializing) return; + this.emit('debug', `Invalidating transition ${transitionId}`); + this.reinitializing = true; + this.consecutiveFailures = 0; + this.emit('invalidateTransition', transitionId); + this.reinit(); + } + + /** + * Processes proposals from the MLS group. + * + * @param payload - The binary message payload + * @param connectedClients - The set of connected client IDs + * @returns The payload to send back to the voice server, if there is one + */ + public processProposals(payload: Buffer, connectedClients: Set): Buffer | undefined { + if (!this.session) throw new Error('No session available'); + const optype = payload.readUInt8(0) as 0 | 1; + const { commit, welcome } = this.session.processProposals( + optype, + payload.subarray(1), + Array.from(connectedClients), + ); + this.emit('debug', 'MLS proposals processed'); + if (!commit) return; + return welcome ? Buffer.concat([commit, welcome]) : commit; + } + + /** + * Processes a commit from the MLS group. + * + * @param payload - The payload + * @returns The transaction id and whether it was successful + */ + public processCommit(payload: Buffer): TransitionResult { + if (!this.session) throw new Error('No session available'); + const transitionId = payload.readUInt16BE(0); + try { + this.session.processCommit(payload.subarray(2)); + if (transitionId === 0) { + this.reinitializing = false; + this.lastTransitionId = transitionId; + } else { + this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion }; + } + + this.emit('debug', `MLS commit processed (transition id: ${transitionId})`); + return { transitionId, success: true }; + } catch (error) { + this.emit('debug', `MLS commit errored from transition ${transitionId}: ${error}`); + this.recoverFromInvalidTransition(transitionId); + return { transitionId, success: false }; + } + } + + /** + * Processes a welcome from the MLS group. + * + * @param payload - The payload + * @returns The transaction id and whether it was successful + */ + public processWelcome(payload: Buffer): TransitionResult { + if (!this.session) throw new Error('No session available'); + const transitionId = payload.readUInt16BE(0); + try { + this.session.processWelcome(payload.subarray(2)); + if (transitionId === 0) { + this.reinitializing = false; + this.lastTransitionId = transitionId; + } else { + this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion }; + } + + this.emit('debug', `MLS welcome processed (transition id: ${transitionId})`); + return { transitionId, success: true }; + } catch (error) { + this.emit('debug', `MLS welcome errored from transition ${transitionId}: ${error}`); + this.recoverFromInvalidTransition(transitionId); + return { transitionId, success: false }; + } + } + + /** + * Encrypt a packet using end-to-end encryption. + * + * @param packet - The packet to encrypt + */ + public encrypt(packet: Buffer) { + if (this.protocolVersion === 0 || !this.session?.ready || packet.equals(SILENCE_FRAME)) return packet; + return this.session.encryptOpus(packet); + } + + /** + * Decrypt a packet using end-to-end encryption. + * + * @param packet - The packet to decrypt + * @param userId - The user id that sent the packet + * @returns The decrypted packet, or `null` if the decryption failed but should be ignored + */ + public decrypt(packet: Buffer, userId: string) { + const canDecrypt = this.session?.ready && (this.protocolVersion !== 0 || this.session?.canPassthrough(userId)); + if (packet.equals(SILENCE_FRAME) || !canDecrypt || !this.session) return packet; + try { + const buffer = this.session.decrypt(userId, Davey.MediaType.AUDIO, packet); + this.consecutiveFailures = 0; + return buffer; + } catch (error) { + if (!this.reinitializing && !this.pendingTransition) { + this.consecutiveFailures++; + this.emit('debug', `Failed to decrypt a packet (${this.consecutiveFailures} consecutive fails)`); + if (this.consecutiveFailures > this.failureTolerance) { + if (this.lastTransitionId) this.recoverFromInvalidTransition(this.lastTransitionId); + else throw error; + } + } else if (this.reinitializing) { + this.emit('debug', 'Failed to decrypt a packet (reinitializing session)'); + } else if (this.pendingTransition) { + this.emit( + 'debug', + `Failed to decrypt a packet (pending transition ${this.pendingTransition.transition_id} to v${this.pendingTransition.protocol_version})`, + ); + } + } + + return null; + } + + /** + * Resets the session. + */ + public destroy() { + try { + this.session?.reset(); + } catch {} + } +} diff --git a/packages/voice/src/networking/Networking.ts b/packages/voice/src/networking/Networking.ts index 64952c564..5c1ff6e8f 100644 --- a/packages/voice/src/networking/Networking.ts +++ b/packages/voice/src/networking/Networking.ts @@ -4,11 +4,14 @@ import { Buffer } from 'node:buffer'; import crypto from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { VoiceOpcodes } from 'discord-api-types/voice/v4'; +import type { VoiceReceivePayload, VoiceSpeakingFlags } from 'discord-api-types/voice/v8'; +import { VoiceEncryptionMode, VoiceOpcodes } from 'discord-api-types/voice/v8'; import type { CloseEvent } from 'ws'; import * as secretbox from '../util/Secretbox'; import { noop } from '../util/util'; +import { DAVESession, getMaxProtocolVersion } from './DAVESession'; import { VoiceUDPSocket } from './VoiceUDPSocket'; +import type { BinaryWebSocketMessage } from './VoiceWebSocket'; import { VoiceWebSocket } from './VoiceWebSocket'; // The number of audio channels required by Discord @@ -16,11 +19,11 @@ const CHANNELS = 2; const TIMESTAMP_INC = (48_000 / 100) * CHANNELS; const MAX_NONCE_SIZE = 2 ** 32 - 1; -export const SUPPORTED_ENCRYPTION_MODES = ['aead_xchacha20_poly1305_rtpsize']; +export const SUPPORTED_ENCRYPTION_MODES: VoiceEncryptionMode[] = [VoiceEncryptionMode.AeadXChaCha20Poly1305RtpSize]; // Just in case there's some system that doesn't come with aes-256-gcm, conditionally add it as supported if (crypto.getCiphers().includes('aes-256-gcm')) { - SUPPORTED_ENCRYPTION_MODES.unshift('aead_aes256_gcm_rtpsize'); + SUPPORTED_ENCRYPTION_MODES.unshift(VoiceEncryptionMode.AeadAes256GcmRtpSize); } /** @@ -63,7 +66,7 @@ export interface NetworkingIdentifyingState { */ export interface NetworkingUdpHandshakingState { code: NetworkingStatusCode.UdpHandshaking; - connectionData: Pick; + connectionData: Pick; connectionOptions: ConnectionOptions; udp: VoiceUDPSocket; ws: VoiceWebSocket; @@ -74,7 +77,7 @@ export interface NetworkingUdpHandshakingState { */ export interface NetworkingSelectingProtocolState { code: NetworkingStatusCode.SelectingProtocol; - connectionData: Pick; + connectionData: Pick; connectionOptions: ConnectionOptions; udp: VoiceUDPSocket; ws: VoiceWebSocket; @@ -88,6 +91,7 @@ export interface NetworkingReadyState { code: NetworkingStatusCode.Ready; connectionData: ConnectionData; connectionOptions: ConnectionOptions; + dave?: DAVESession | undefined; preparedPacket?: Buffer | undefined; udp: VoiceUDPSocket; ws: VoiceWebSocket; @@ -101,6 +105,7 @@ export interface NetworkingResumingState { code: NetworkingStatusCode.Resuming; connectionData: ConnectionData; connectionOptions: ConnectionOptions; + dave?: DAVESession | undefined; preparedPacket?: Buffer | undefined; udp: VoiceUDPSocket; ws: VoiceWebSocket; @@ -132,6 +137,7 @@ export type NetworkingState = * and VOICE_STATE_UPDATE packets. */ export interface ConnectionOptions { + channelId: string; endpoint: string; serverId: string; sessionId: string; @@ -144,6 +150,7 @@ export interface ConnectionOptions { * the connection, timing information for playback of streams. */ export interface ConnectionData { + connectedClients: Set; encryptionMode: string; nonce: number; nonceBuffer: Buffer; @@ -155,6 +162,15 @@ export interface ConnectionData { timestamp: number; } +/** + * Options for networking that dictate behavior. + */ +export interface NetworkingOptions { + daveEncryption?: boolean | undefined; + debug?: boolean | undefined; + decryptionFailureTolerance?: number | undefined; +} + /** * An empty buffer that is reused in packet encryption by many different networking instances. */ @@ -170,6 +186,7 @@ export interface Networking extends EventEmitter { on(event: 'error', listener: (error: Error) => void): this; on(event: 'stateChange', listener: (oldState: NetworkingState, newState: NetworkingState) => void): this; on(event: 'close', listener: (code: number) => void): this; + on(event: 'transitioned', listener: (transitionId: number) => void): this; } /** @@ -190,7 +207,7 @@ function stringifyState(state: NetworkingState) { * * @param options - The available encryption options */ -function chooseEncryptionMode(options: string[]): string { +function chooseEncryptionMode(options: VoiceEncryptionMode[]): VoiceEncryptionMode { const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option)); if (!option) { // This should only ever happen if the gateway does not give us any encryption modes we support. @@ -220,27 +237,37 @@ export class Networking extends EventEmitter { */ private readonly debug: ((message: string) => void) | null; + /** + * The options used to create this Networking instance. + */ + private readonly options: NetworkingOptions; + /** * Creates a new Networking instance. */ - public constructor(options: ConnectionOptions, debug: boolean) { + public constructor(connectionOptions: ConnectionOptions, options: NetworkingOptions) { super(); this.onWsOpen = this.onWsOpen.bind(this); this.onChildError = this.onChildError.bind(this); this.onWsPacket = this.onWsPacket.bind(this); + this.onWsBinary = this.onWsBinary.bind(this); this.onWsClose = this.onWsClose.bind(this); this.onWsDebug = this.onWsDebug.bind(this); this.onUdpDebug = this.onUdpDebug.bind(this); this.onUdpClose = this.onUdpClose.bind(this); + this.onDaveDebug = this.onDaveDebug.bind(this); + this.onDaveKeyPackage = this.onDaveKeyPackage.bind(this); + this.onDaveInvalidateTransition = this.onDaveInvalidateTransition.bind(this); - this.debug = debug ? (message: string) => this.emit('debug', message) : null; + this.debug = options?.debug ? (message: string) => this.emit('debug', message) : null; this._state = { code: NetworkingStatusCode.OpeningWs, - ws: this.createWebSocket(options.endpoint), - connectionOptions: options, + ws: this.createWebSocket(connectionOptions.endpoint), + connectionOptions, }; + this.options = options; } /** @@ -272,6 +299,7 @@ export class Networking extends EventEmitter { oldWs.off('error', this.onChildError); oldWs.off('open', this.onWsOpen); oldWs.off('packet', this.onWsPacket); + oldWs.off('binary', this.onWsBinary); oldWs.off('close', this.onWsClose); oldWs.destroy(); } @@ -287,6 +315,17 @@ export class Networking extends EventEmitter { oldUdp.destroy(); } + const oldDave = Reflect.get(this._state, 'dave') as DAVESession | undefined; + const newDave = Reflect.get(newState, 'dave') as DAVESession | undefined; + + if (oldDave && oldDave !== newDave) { + oldDave.off('error', this.onChildError); + oldDave.off('debug', this.onDaveDebug); + oldDave.off('keyPackage', this.onDaveKeyPackage); + oldDave.off('invalidateTransition', this.onDaveInvalidateTransition); + oldDave.destroy(); + } + const oldState = this._state; this._state = newState; this.emit('stateChange', oldState, newState); @@ -310,6 +349,7 @@ export class Networking extends EventEmitter { ws.on('error', this.onChildError); ws.once('open', this.onWsOpen); ws.on('packet', this.onWsPacket); + ws.on('binary', this.onWsBinary); ws.once('close', this.onWsClose); ws.on('debug', this.onWsDebug); @@ -317,7 +357,41 @@ export class Networking extends EventEmitter { } /** - * Propagates errors from the children VoiceWebSocket and VoiceUDPSocket. + * Creates a new DAVE session for this voice connection if we can create one. + * + * @param protocolVersion - The protocol version to use + */ + private createDaveSession(protocolVersion: number) { + if ( + getMaxProtocolVersion() === null || + this.options.daveEncryption === false || + (this.state.code !== NetworkingStatusCode.SelectingProtocol && + this.state.code !== NetworkingStatusCode.Ready && + this.state.code !== NetworkingStatusCode.Resuming) + ) { + return; + } + + const session = new DAVESession( + protocolVersion, + this.state.connectionOptions.userId, + this.state.connectionOptions.channelId, + { + decryptionFailureTolerance: this.options.decryptionFailureTolerance, + }, + ); + + session.on('error', this.onChildError); + session.on('debug', this.onDaveDebug); + session.on('keyPackage', this.onDaveKeyPackage); + session.on('invalidateTransition', this.onDaveInvalidateTransition); + session.reinit(); + + return session; + } + + /** + * Propagates errors from the children VoiceWebSocket, VoiceUDPSocket and DAVESession. * * @param error - The error that was emitted by a child */ @@ -331,22 +405,22 @@ export class Networking extends EventEmitter { */ private onWsOpen() { if (this.state.code === NetworkingStatusCode.OpeningWs) { - const packet = { + this.state.ws.sendPacket({ op: VoiceOpcodes.Identify, d: { server_id: this.state.connectionOptions.serverId, user_id: this.state.connectionOptions.userId, session_id: this.state.connectionOptions.sessionId, token: this.state.connectionOptions.token, + max_dave_protocol_version: this.options.daveEncryption === false ? 0 : (getMaxProtocolVersion() ?? 0), }, - }; - this.state.ws.sendPacket(packet); + }); this.state = { ...this.state, code: NetworkingStatusCode.Identifying, }; } else if (this.state.code === NetworkingStatusCode.Resuming) { - const packet = { + this.state.ws.sendPacket({ op: VoiceOpcodes.Resume, d: { server_id: this.state.connectionOptions.serverId, @@ -354,8 +428,7 @@ export class Networking extends EventEmitter { token: this.state.connectionOptions.token, seq_ack: this.state.ws.sequence, }, - }; - this.state.ws.sendPacket(packet); + }); } } @@ -400,7 +473,7 @@ export class Networking extends EventEmitter { * * @param packet - The received packet */ - private onWsPacket(packet: any) { + private onWsPacket(packet: VoiceReceivePayload) { if (packet.op === VoiceOpcodes.Hello && this.state.code !== NetworkingStatusCode.Closed) { this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval); } else if (packet.op === VoiceOpcodes.Ready && this.state.code === NetworkingStatusCode.Identifying) { @@ -440,16 +513,18 @@ export class Networking extends EventEmitter { udp, connectionData: { ssrc, + connectedClients: new Set(), }, }; } else if ( packet.op === VoiceOpcodes.SessionDescription && this.state.code === NetworkingStatusCode.SelectingProtocol ) { - const { mode: encryptionMode, secret_key: secretKey } = packet.d; + const { mode: encryptionMode, secret_key: secretKey, dave_protocol_version: daveProtocolVersion } = packet.d; this.state = { ...this.state, code: NetworkingStatusCode.Ready, + dave: this.createDaveSession(daveProtocolVersion), connectionData: { ...this.state.connectionData, encryptionMode, @@ -468,9 +543,99 @@ export class Networking extends EventEmitter { code: NetworkingStatusCode.Ready, }; this.state.connectionData.speaking = false; + } else if ( + (packet.op === VoiceOpcodes.ClientsConnect || packet.op === VoiceOpcodes.ClientDisconnect) && + (this.state.code === NetworkingStatusCode.Ready || + this.state.code === NetworkingStatusCode.UdpHandshaking || + this.state.code === NetworkingStatusCode.SelectingProtocol || + this.state.code === NetworkingStatusCode.Resuming) + ) { + const { connectionData } = this.state; + if (packet.op === VoiceOpcodes.ClientsConnect) + for (const id of packet.d.user_ids) connectionData.connectedClients.add(id); + else { + connectionData.connectedClients.delete(packet.d.user_id); + } + } else if ( + (this.state.code === NetworkingStatusCode.Ready || this.state.code === NetworkingStatusCode.Resuming) && + this.state.dave + ) { + if (packet.op === VoiceOpcodes.DavePrepareTransition) { + const sendReady = this.state.dave.prepareTransition(packet.d); + if (sendReady) + this.state.ws.sendPacket({ + op: VoiceOpcodes.DaveTransitionReady, + d: { transition_id: packet.d.transition_id }, + }); + if (packet.d.transition_id === 0) { + this.emit('transitioned', 0); + } + } else if (packet.op === VoiceOpcodes.DaveExecuteTransition) { + const transitioned = this.state.dave.executeTransition(packet.d.transition_id); + if (transitioned) this.emit('transitioned', packet.d.transition_id); + } else if (packet.op === VoiceOpcodes.DavePrepareEpoch) this.state.dave.prepareEpoch(packet.d); } } + /** + * Called when a binary message is received on the connection's WebSocket. + * + * @param message - The received message + */ + private onWsBinary(message: BinaryWebSocketMessage) { + if (this.state.code === NetworkingStatusCode.Ready && this.state.dave) { + if (message.op === VoiceOpcodes.DaveMlsExternalSender) { + this.state.dave.setExternalSender(message.payload); + } else if (message.op === VoiceOpcodes.DaveMlsProposals) { + const payload = this.state.dave.processProposals(message.payload, this.state.connectionData.connectedClients); + if (payload) this.state.ws.sendBinaryMessage(VoiceOpcodes.DaveMlsCommitWelcome, payload); + } else if (message.op === VoiceOpcodes.DaveMlsAnnounceCommitTransition) { + const { transitionId, success } = this.state.dave.processCommit(message.payload); + if (success) { + if (transitionId === 0) this.emit('transitioned', transitionId); + else + this.state.ws.sendPacket({ + op: VoiceOpcodes.DaveTransitionReady, + d: { transition_id: transitionId }, + }); + } + } else if (message.op === VoiceOpcodes.DaveMlsWelcome) { + const { transitionId, success } = this.state.dave.processWelcome(message.payload); + if (success) { + if (transitionId === 0) this.emit('transitioned', transitionId); + else + this.state.ws.sendPacket({ + op: VoiceOpcodes.DaveTransitionReady, + d: { transition_id: transitionId }, + }); + } + } + } + } + + /** + * Called when a new key package is ready to be sent to the voice server. + * + * @param keyPackage - The new key package + */ + private onDaveKeyPackage(keyPackage: Buffer) { + if (this.state.code === NetworkingStatusCode.SelectingProtocol || this.state.code === NetworkingStatusCode.Ready) + this.state.ws.sendBinaryMessage(VoiceOpcodes.DaveMlsKeyPackage, keyPackage); + } + + /** + * Called when the DAVE session wants to invalidate their transition and re-initialize. + * + * @param transitionId - The transition to invalidate + */ + private onDaveInvalidateTransition(transitionId: number) { + if (this.state.code === NetworkingStatusCode.SelectingProtocol || this.state.code === NetworkingStatusCode.Ready) + this.state.ws.sendPacket({ + op: VoiceOpcodes.DaveMlsInvalidCommitWelcome, + d: { transition_id: transitionId }, + }); + } + /** * Propagates debug messages from the child WebSocket. * @@ -489,6 +654,15 @@ export class Networking extends EventEmitter { this.debug?.(`[UDP] ${message}`); } + /** + * Propagates debug messages from the child DAVESession. + * + * @param message - The emitted debug message + */ + private onDaveDebug(message: string) { + this.debug?.(`[DAVE] ${message}`); + } + /** * Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it. * It will be stored within the instance, and can be played by dispatchAudio() @@ -502,7 +676,7 @@ export class Networking extends EventEmitter { public prepareAudioPacket(opusPacket: Buffer) { const state = this.state; if (state.code !== NetworkingStatusCode.Ready) return; - state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData); + state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData, state.dave); return state.preparedPacket; } @@ -554,7 +728,7 @@ export class Networking extends EventEmitter { state.ws.sendPacket({ op: VoiceOpcodes.Speaking, d: { - speaking: speaking ? 1 : 0, + speaking: (speaking ? 1 : 0) as VoiceSpeakingFlags, delay: 0, ssrc: state.connectionData.ssrc, }, @@ -567,8 +741,9 @@ export class Networking extends EventEmitter { * * @param opusPacket - The Opus packet to prepare * @param connectionData - The current connection data of the instance + * @param daveSession - The DAVE session to use for encryption */ - private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) { + private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData, daveSession?: DAVESession) { const rtpHeader = Buffer.alloc(12); rtpHeader[0] = 0x80; rtpHeader[1] = 0x78; @@ -580,7 +755,7 @@ export class Networking extends EventEmitter { rtpHeader.writeUIntBE(ssrc, 8, 4); rtpHeader.copy(nonce, 0, 0, 12); - return Buffer.concat([rtpHeader, ...this.encryptOpusPacket(opusPacket, connectionData, rtpHeader)]); + return Buffer.concat([rtpHeader, ...this.encryptOpusPacket(opusPacket, connectionData, rtpHeader, daveSession)]); } /** @@ -588,10 +763,18 @@ export class Networking extends EventEmitter { * * @param opusPacket - The Opus packet to encrypt * @param connectionData - The current connection data of the instance + * @param daveSession - The DAVE session to use for encryption */ - private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData, additionalData: Buffer) { + private encryptOpusPacket( + opusPacket: Buffer, + connectionData: ConnectionData, + additionalData: Buffer, + daveSession?: DAVESession, + ) { const { secretKey, encryptionMode } = connectionData; + const packet = daveSession?.encrypt(opusPacket) ?? opusPacket; + // Both supported encryption methods want the nonce to be an incremental integer connectionData.nonce++; if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0; @@ -606,14 +789,14 @@ export class Networking extends EventEmitter { const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, connectionData.nonceBuffer); cipher.setAAD(additionalData); - encrypted = Buffer.concat([cipher.update(opusPacket), cipher.final(), cipher.getAuthTag()]); + encrypted = Buffer.concat([cipher.update(packet), cipher.final(), cipher.getAuthTag()]); return [encrypted, noncePadding]; } case 'aead_xchacha20_poly1305_rtpsize': { encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt( - opusPacket, + packet, additionalData, connectionData.nonceBuffer, secretKey, diff --git a/packages/voice/src/networking/VoiceWebSocket.ts b/packages/voice/src/networking/VoiceWebSocket.ts index 6a6c09205..5047f7a94 100644 --- a/packages/voice/src/networking/VoiceWebSocket.ts +++ b/packages/voice/src/networking/VoiceWebSocket.ts @@ -1,7 +1,18 @@ +import { Buffer } from 'node:buffer'; import { EventEmitter } from 'node:events'; -import { VoiceOpcodes } from 'discord-api-types/voice/v4'; +import type { VoiceSendPayload } from 'discord-api-types/voice/v8'; +import { VoiceOpcodes } from 'discord-api-types/voice/v8'; import WebSocket, { type MessageEvent } from 'ws'; +/** + * A binary WebSocket message. + */ +export interface BinaryWebSocketMessage { + op: VoiceOpcodes; + payload: Buffer; + seq: number; +} + export interface VoiceWebSocket extends EventEmitter { on(event: 'error', listener: (error: Error) => void): this; on(event: 'open', listener: (event: WebSocket.Event) => void): this; @@ -18,6 +29,12 @@ export interface VoiceWebSocket extends EventEmitter { * @eventProperty */ on(event: 'packet', listener: (packet: any) => void): this; + /** + * Binary message event. + * + * @eventProperty + */ + on(event: 'binary', listener: (message: BinaryWebSocketMessage) => void): this; } /** @@ -102,12 +119,25 @@ export class VoiceWebSocket extends EventEmitter { /** * Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them - * as packets. + * as packets. Binary messages will be parsed and emitted. * * @param event - The message event */ public onMessage(event: MessageEvent) { - if (typeof event.data !== 'string') return; + if (event.data instanceof Buffer || event.data instanceof ArrayBuffer) { + const buffer = event.data instanceof ArrayBuffer ? Buffer.from(event.data) : event.data; + const seq = buffer.readUInt16BE(0); + const op = buffer.readUInt8(2); + const payload = buffer.subarray(3); + + this.sequence = seq; + this.debug?.(`<< [bin] opcode ${op}, seq ${seq}, ${payload.byteLength} bytes`); + + this.emit('binary', { op, seq, payload }); + return; + } else if (typeof event.data !== 'string') { + return; + } this.debug?.(`<< ${event.data}`); @@ -138,7 +168,7 @@ export class VoiceWebSocket extends EventEmitter { * * @param packet - The packet to send */ - public sendPacket(packet: any) { + public sendPacket(packet: VoiceSendPayload) { try { const stringified = JSON.stringify(packet); this.debug?.(`>> ${stringified}`); @@ -149,6 +179,23 @@ export class VoiceWebSocket extends EventEmitter { } } + /** + * Sends a binary mesasge over the WebSocket. + * + * @param opcode - The opcode to use + * @param payload - The payload to send + */ + public sendBinaryMessage(opcode: VoiceOpcodes, payload: Buffer) { + try { + const message = Buffer.concat([new Uint8Array([opcode]), payload]); + this.debug?.(`>> [bin] opcode ${opcode}, ${payload.byteLength} bytes`); + this.ws.send(message); + } catch (error) { + const err = error as Error; + this.emit('error', err); + } + } + /** * Sends a heartbeat over the WebSocket. */ diff --git a/packages/voice/src/networking/index.ts b/packages/voice/src/networking/index.ts index 59d29bd52..505d119e2 100644 --- a/packages/voice/src/networking/index.ts +++ b/packages/voice/src/networking/index.ts @@ -1,3 +1,4 @@ export * from './Networking'; export * from './VoiceUDPSocket'; export * from './VoiceWebSocket'; +export * from './DAVESession'; diff --git a/packages/voice/src/receive/VoiceReceiver.ts b/packages/voice/src/receive/VoiceReceiver.ts index 63785a761..702810c3a 100644 --- a/packages/voice/src/receive/VoiceReceiver.ts +++ b/packages/voice/src/receive/VoiceReceiver.ts @@ -2,9 +2,10 @@ import { Buffer } from 'node:buffer'; import crypto from 'node:crypto'; -import { VoiceOpcodes } from 'discord-api-types/voice/v4'; -import type { VoiceConnection } from '../VoiceConnection'; -import type { ConnectionData } from '../networking/Networking'; +import type { VoiceReceivePayload } from 'discord-api-types/voice/v8'; +import { VoiceOpcodes } from 'discord-api-types/voice/v8'; +import { VoiceConnectionStatus, type VoiceConnection } from '../VoiceConnection'; +import { NetworkingStatusCode, type ConnectionData } from '../networking/Networking'; import { methods } from '../util/Secretbox'; import { AudioReceiveStream, @@ -69,25 +70,11 @@ export class VoiceReceiver { * @param packet - The received packet * @internal */ - public onWsPacket(packet: any) { - if (packet.op === VoiceOpcodes.ClientDisconnect && typeof packet.d?.user_id === 'string') { + public onWsPacket(packet: VoiceReceivePayload) { + if (packet.op === VoiceOpcodes.ClientDisconnect) { this.ssrcMap.delete(packet.d.user_id); - } else if ( - packet.op === VoiceOpcodes.Speaking && - typeof packet.d?.user_id === 'string' && - typeof packet.d?.ssrc === 'number' - ) { + } else if (packet.op === VoiceOpcodes.Speaking) { this.ssrcMap.update({ userId: packet.d.user_id, audioSSRC: packet.d.ssrc }); - } else if ( - packet.op === VoiceOpcodes.ClientConnect && - typeof packet.d?.user_id === 'string' && - typeof packet.d?.audio_ssrc === 'number' - ) { - this.ssrcMap.update({ - userId: packet.d.user_id, - audioSSRC: packet.d.audio_ssrc, - videoSSRC: packet.d.video_ssrc === 0 ? undefined : packet.d.video_ssrc, - }); } } @@ -143,11 +130,12 @@ export class VoiceReceiver { * @param mode - The encryption mode * @param nonce - The nonce buffer used by the connection for encryption * @param secretKey - The secret key used by the connection for encryption + * @param userId - The user id that sent the packet * @returns The parsed Opus packet */ - private parsePacket(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array) { + private parsePacket(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array, userId: string) { let packet = this.decrypt(buffer, mode, nonce, secretKey); - if (!packet) return; + if (!packet) throw new Error('Failed to parse packet'); // Strip decrypted RTP Header Extension if present // The header is only indicated in the original data, so compare with buffer first @@ -156,6 +144,16 @@ export class VoiceReceiver { packet = packet.subarray(4 * headerExtensionLength); } + // Decrypt packet if in a DAVE session. + if ( + this.voiceConnection.state.status === VoiceConnectionStatus.Ready && + (this.voiceConnection.state.networking.state.code === NetworkingStatusCode.Ready || + this.voiceConnection.state.networking.state.code === NetworkingStatusCode.Resuming) + ) { + const daveSession = this.voiceConnection.state.networking.state.dave; + if (daveSession) packet = daveSession.decrypt(packet, userId)!; + } + return packet; } @@ -178,16 +176,17 @@ export class VoiceReceiver { if (!stream) return; if (this.connectionData.encryptionMode && this.connectionData.nonceBuffer && this.connectionData.secretKey) { - const packet = this.parsePacket( - msg, - this.connectionData.encryptionMode, - this.connectionData.nonceBuffer, - this.connectionData.secretKey, - ); - if (packet) { - stream.push(packet); - } else { - stream.destroy(new Error('Failed to parse packet')); + try { + const packet = this.parsePacket( + msg, + this.connectionData.encryptionMode, + this.connectionData.nonceBuffer, + this.connectionData.secretKey, + userData.userId, + ); + if (packet) stream.push(packet); + } catch (error) { + stream.destroy(error as Error); } } } diff --git a/packages/voice/src/util/generateDependencyReport.ts b/packages/voice/src/util/generateDependencyReport.ts index 5fedaa3da..ae49d708d 100644 --- a/packages/voice/src/util/generateDependencyReport.ts +++ b/packages/voice/src/util/generateDependencyReport.ts @@ -74,6 +74,11 @@ export function generateDependencyReport() { addVersion('@noble/ciphers'); report.push(''); + // dave + report.push('DAVE Libraries'); + addVersion('@snazzah/davey'); + report.push(''); + // ffmpeg report.push('FFmpeg'); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9eea1ab3..9eedd358d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1502,7 +1502,7 @@ importers: version: 7.8.0 yaml: specifier: ^2.7.1 - version: 2.7.1 + version: 2.8.0 devDependencies: '@turbo/gen': specifier: ^2.5.0 @@ -1536,7 +1536,7 @@ importers: version: 5.39.0 tsup: specifier: ^8.4.0 - version: 8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.7.1) + version: 8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0) turbo: specifier: ^2.5.2 version: 2.5.3 @@ -1798,6 +1798,9 @@ importers: '@noble/ciphers': specifier: ^1.2.1 version: 1.2.1 + '@snazzah/davey': + specifier: ^0.1.6 + version: 0.1.6 '@types/node': specifier: ^22.15.2 version: 22.15.26 @@ -3831,6 +3834,9 @@ packages: '@napi-rs/wasm-runtime@0.2.10': resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==} + '@napi-rs/wasm-runtime@0.2.11': + resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@neondatabase/serverless@0.9.5': resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==} @@ -5928,6 +5934,93 @@ packages: resolution: {integrity: sha512-JtaY3FxmD+te+KSI2FJuEcfNC9T/DGGVf551babM7fAaXhjJUt7oSYurH1Devxd2+BOSUACCgt3buinx4UnmEA==} engines: {node: '>=18.0.0'} + '@snazzah/davey-android-arm-eabi@0.1.6': + resolution: {integrity: sha512-6Fso+kxvvIcmUdTgU4etHjvEZUwGwvIk+SUYxKTRZKz/S62pZvcFeZfbofpQC5ZIlt/rdp7l+4IM62J7PUduxQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@snazzah/davey-android-arm64@0.1.6': + resolution: {integrity: sha512-5ZGLumjewJAmGAcHqSHb2+KZSSufdNY++/GouzqdQXfhs2bSNBPuHpNn94u6//5UK0o73udJ6B1H/uLOLfEBLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@snazzah/davey-darwin-arm64@0.1.6': + resolution: {integrity: sha512-0k6gOm29bcznz4ND1gfJVKeCxfyFw/EtfhPQvQ2PPJToSIaSvVqfYIlj/v9ogWW/lzuPI4EbLP0b6hnZkKidbQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@snazzah/davey-darwin-x64@0.1.6': + resolution: {integrity: sha512-y9UuymB5JTi9LSwjsCZDf/mjI6nAum1+uYX2h4xdO+VUxXQSAR4B2mr3lCI7l9KwYqW7JVDN5wETithAkXcTYA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@snazzah/davey-freebsd-x64@0.1.6': + resolution: {integrity: sha512-G0XzHi+pZqTZ5Zr7Z66J6oGOG07+Obw7f0CwD9nAJcSFlKnd8wYzTjL+krHfQxmLHnuA5w/9df+M9oDJDcGcJw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@snazzah/davey-linux-arm-gnueabihf@0.1.6': + resolution: {integrity: sha512-RaxTzO8iJfDvj4a8OcXRwcP+2WfaCcno28ZWFMTI0pHEviG3MfLH5COAIvtMQvg0XfC+HgFC4YA1d29S8Dhvbg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@snazzah/davey-linux-arm64-gnu@0.1.6': + resolution: {integrity: sha512-2BIJSWs4rHq4U9A7B6WtF1LzwYJrbFUz5SQVmwqwQXKJ8cm81iizqclDGWr3zFGiVPTXLZ/+G3wnQNDB54oABQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-arm64-musl@0.1.6': + resolution: {integrity: sha512-N1egO+HT8cvSdIGCzJNRVH3ZhxCIYKVYxEkfzVZaBx26snN8NF737YTVRldl84w//3tdgohyl27yrn+dMkWS2Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-x64-gnu@0.1.6': + resolution: {integrity: sha512-RAC96Y//HHoMP+1MUf4rOkBq5Nx6GCiOGeGsNXt7r02lbIthoFEPYFQdbfc9jYA79k67gpzmCa0N5ws7ZLVU5A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-linux-x64-musl@0.1.6': + resolution: {integrity: sha512-nBvxJTKlQFP9UsQ7ah78L+rGdcwLWKDR8z/knut/M+UZLe37vaponJAbY3F5ZqGAcfqJbwUi/CXR77t9E+TDmw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-wasm32-wasi@0.1.6': + resolution: {integrity: sha512-hvpZH6a4mYZiXv6vdZaFwjPProgFtb3k4BoMvEEJZDXsEPuIDgp+d2BX5Q9nVazdnJa/6JR/XCuObzugPWp0Ew==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@snazzah/davey-win32-arm64-msvc@0.1.6': + resolution: {integrity: sha512-iuxYXXa0Z8eAEZotAlMYUc5DCy3VonRXQMm8/w2EvM/ZzGBI7SMap0GhPf6HjArEW32ETarTLh1s/Yi/jhFPDQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@snazzah/davey-win32-ia32-msvc@0.1.6': + resolution: {integrity: sha512-QEubcCIBR+ZZoQzRzJuOuKcH2IaF2pFXU+t48ITHG1o2WL4NAnvc3IpfVQGhbkr+DlydZ6fKNMMEemd1pRZzRA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@snazzah/davey-win32-x64-msvc@0.1.6': + resolution: {integrity: sha512-8tR3o+amQOHJL8QzSwuSCCave+jm3SC1m1OKSh9Coy4wN/XoJN0XQUxqzA7ineSClAMW3yIO0ShFmMlMIXsC0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@snazzah/davey@0.1.6': + resolution: {integrity: sha512-wKJDQ7iobl3rvuQDXLC2yZdpuVxPvMnbyjyPpkcETqPfqNVrdyX9zSdV74dnkpx7aLpINEmKh8ZEIlCIJA2h1w==} + engines: {node: '>= 10'} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -8812,6 +8905,9 @@ packages: discord-api-types@0.38.15: resolution: {integrity: sha512-RX3skyRH7p6BlHOW62ztdnIc87+wv4TEJEURMir5k5BbRJ10wK1MCqFEO6USHTol3gkiHLE6wWoHhNQ2pqB4AA==} + discord-api-types@0.38.11: + resolution: {integrity: sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw==} + dmd@6.2.3: resolution: {integrity: sha512-SIEkjrG7cZ9GWZQYk/mH+mWtcRPly/3ibVuXO/tP/MFoWz6KiRK77tSMq6YQBPl7RljPtXPQ/JhxbNuCdi1bNw==} engines: {node: '>=12'} @@ -12095,6 +12191,7 @@ packages: path-match@1.2.4: resolution: {integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==} + deprecated: This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -14380,11 +14477,6 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.7.1: - resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} - engines: {node: '>= 14'} - hasBin: true - yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} @@ -16711,6 +16803,13 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true + '@napi-rs/wasm-runtime@0.2.11': + dependencies: + '@emnapi/core': 1.4.3 + '@emnapi/runtime': 1.4.3 + '@tybys/wasm-util': 0.9.0 + optional: true + '@neondatabase/serverless@0.9.5': dependencies: '@types/pg': 8.11.6 @@ -19314,6 +19413,67 @@ snapshots: '@smithy/types': 4.2.0 tslib: 2.8.1 + '@snazzah/davey-android-arm-eabi@0.1.6': + optional: true + + '@snazzah/davey-android-arm64@0.1.6': + optional: true + + '@snazzah/davey-darwin-arm64@0.1.6': + optional: true + + '@snazzah/davey-darwin-x64@0.1.6': + optional: true + + '@snazzah/davey-freebsd-x64@0.1.6': + optional: true + + '@snazzah/davey-linux-arm-gnueabihf@0.1.6': + optional: true + + '@snazzah/davey-linux-arm64-gnu@0.1.6': + optional: true + + '@snazzah/davey-linux-arm64-musl@0.1.6': + optional: true + + '@snazzah/davey-linux-x64-gnu@0.1.6': + optional: true + + '@snazzah/davey-linux-x64-musl@0.1.6': + optional: true + + '@snazzah/davey-wasm32-wasi@0.1.6': + dependencies: + '@napi-rs/wasm-runtime': 0.2.11 + optional: true + + '@snazzah/davey-win32-arm64-msvc@0.1.6': + optional: true + + '@snazzah/davey-win32-ia32-msvc@0.1.6': + optional: true + + '@snazzah/davey-win32-x64-msvc@0.1.6': + optional: true + + '@snazzah/davey@0.1.6': + optionalDependencies: + '@snazzah/davey-android-arm-eabi': 0.1.6 + '@snazzah/davey-android-arm64': 0.1.6 + '@snazzah/davey-darwin-arm64': 0.1.6 + '@snazzah/davey-darwin-x64': 0.1.6 + '@snazzah/davey-freebsd-x64': 0.1.6 + '@snazzah/davey-linux-arm-gnueabihf': 0.1.6 + '@snazzah/davey-linux-arm64-gnu': 0.1.6 + '@snazzah/davey-linux-arm64-musl': 0.1.6 + '@snazzah/davey-linux-x64-gnu': 0.1.6 + '@snazzah/davey-linux-x64-musl': 0.1.6 + '@snazzah/davey-wasm32-wasi': 0.1.6 + '@snazzah/davey-win32-arm64-msvc': 0.1.6 + '@snazzah/davey-win32-ia32-msvc': 0.1.6 + '@snazzah/davey-win32-x64-msvc': 0.1.6 + '@standard-schema/spec@1.0.0': {} '@storybook/addon-actions@8.6.12(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.5))': @@ -23130,6 +23290,8 @@ snapshots: discord-api-types@0.38.15: {} + discord-api-types@0.38.11: {} + dmd@6.2.3: dependencies: array-back: 6.2.2 @@ -27793,15 +27955,6 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.7.1): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 2.4.2 - postcss: 8.5.4 - tsx: 4.19.2 - yaml: 2.7.1 - postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.8.0): dependencies: lilconfig: 3.1.3 @@ -29415,35 +29568,6 @@ snapshots: - tsx - yaml - tsup@8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.7.1): - dependencies: - bundle-require: 5.1.0(esbuild@0.25.5) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.1 - esbuild: 0.25.5 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(yaml@2.7.1) - resolve-from: 5.0.0 - rollup: 4.41.1 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.14 - tree-kill: 1.2.2 - optionalDependencies: - '@microsoft/api-extractor': 7.52.3(@types/node@22.15.26) - postcss: 8.5.4 - typescript: 5.8.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsup@8.5.0(@microsoft/api-extractor@7.52.3(@types/node@22.15.26))(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.5) @@ -30333,8 +30457,6 @@ snapshots: yallist@5.0.0: {} - yaml@2.7.1: {} - yaml@2.8.0: {} yargs-parser@20.2.9: {}