diff --git a/packages/voice/__tests__/VoiceConnection.test.ts b/packages/voice/__tests__/VoiceConnection.test.ts index f39f67a3a48d..4c0e822468e2 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 c08700519ed2..7410ce2b2088 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 87fc72ecb07e..04fe58f6b3ae 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 2642f64e7754..948d9b4c317d 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 da64e900cb9b..33f275dfc1af 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 d1186951284f..f14341308ce5 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 15c083c75e1d..0b3a98db5226 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 000000000000..0e3a3de65f76 --- /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 64952c564478..5c1ff6e8f678 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 6a6c09205676..5047f7a94afb 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 59d29bd52a41..505d119e2eee 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 63785a7612f7..702810c3a910 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 5fedaa3daf9b..ae49d708d2f3 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 a9eea1ab3590..9eedd358d4ea 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: {} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy