mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-09 16:13:31 +01:00
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:
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
VoiceUDPSocket,
|
||||
VoiceWebSocket,
|
||||
type SocketConfig,
|
||||
DAVESession,
|
||||
} from './networking/index.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
423
packages/voice/src/networking/DAVESession.ts
Normal file
423
packages/voice/src/networking/DAVESession.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +543,97 @@ 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 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Networking';
|
||||
export * from './VoiceUDPSocket';
|
||||
export * from './VoiceWebSocket';
|
||||
export * from './DAVESession';
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
const packet = this.parsePacket(
|
||||
msg,
|
||||
this.connectionData.encryptionMode,
|
||||
this.connectionData.nonceBuffer,
|
||||
this.connectionData.secretKey,
|
||||
userData.userId,
|
||||
);
|
||||
if (packet) {
|
||||
stream.push(packet);
|
||||
} else {
|
||||
stream.destroy(new Error('Failed to parse packet'));
|
||||
if (packet) stream.push(packet);
|
||||
} catch (error) {
|
||||
stream.destroy(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
216
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user