feat: implement DAVE end-to-end encryption (#10921)

* feat(voice): implement DAVE E2EE encryption

* chore(voice): update dependencies

* chore(voice): update debug logs and dependency report

* feat(voice): emit and propogate DAVESession errors

* chore(voice): export dave session things

* chore(voice): move expiry numbers to consts

* feat(voice): keep track of and pass connected client IDs

* fix(voice): dont set initial transitions as pending

* feat(voice): dave encryption

* chore(voice): directly reference package name in import

* feat(voice): dave decryption

* chore(deps): update @snazzah/davey

* fix(voice): handle decryption failure tolerance

* fix(voice): move and update decryption failure logic to DAVESession

* feat(voice): propogate voice privacy code

* fix(voice): actually send a transition ready when ready

* feat(voice): propogate transitions and verification code function

* feat(voice): add dave options

* chore: resolve format change requests

* chore: emit debug messages on bad transitions

* chore: downgrade commit/welcome errors as debug messages

* chore: resolve formatting change requests

* chore: update davey dependency

* chore: add types for underlying dave session

* fix: fix rebase

* chore: change "ID" to "id" in typedocs

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
This commit is contained in:
Snazzah
2025-07-13 13:02:56 -04:00
committed by GitHub
parent 3cff4d7412
commit 8bdea6232b
14 changed files with 976 additions and 143 deletions

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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",

View File

@@ -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<string> {
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.
*

View File

@@ -19,6 +19,7 @@ export {
VoiceUDPSocket,
VoiceWebSocket,
type SocketConfig,
DAVESession,
} from './networking/index.js';
export {

View File

@@ -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,
});
}

View File

@@ -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<string>;
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<void>(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<string> {
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<string>): 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 {}
}
}

View File

@@ -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, 'ssrc'>;
connectionData: Pick<ConnectionData, 'connectedClients' | 'ssrc'>;
connectionOptions: ConnectionOptions;
udp: VoiceUDPSocket;
ws: VoiceWebSocket;
@@ -74,7 +77,7 @@ export interface NetworkingUdpHandshakingState {
*/
export interface NetworkingSelectingProtocolState {
code: NetworkingStatusCode.SelectingProtocol;
connectionData: Pick<ConnectionData, 'ssrc'>;
connectionData: Pick<ConnectionData, 'connectedClients' | 'ssrc'>;
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<string>;
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,

View File

@@ -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.
*/

View File

@@ -1,3 +1,4 @@
export * from './Networking';
export * from './VoiceUDPSocket';
export * from './VoiceWebSocket';
export * from './DAVESession';

View File

@@ -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);
}
}
}

View File

@@ -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 {

216
pnpm-lock.yaml generated
View File

@@ -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: {}