Skip to content

Commit 400a4be

Browse files
committed
feat(voice): implement DAVE E2EE encryption
1 parent d40ceed commit 400a4be

File tree

10 files changed

+581
-134
lines changed

10 files changed

+581
-134
lines changed

packages/voice/__tests__/VoiceReceiver.test.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { Buffer } from 'node:buffer';
55
import { once } from 'node:events';
66
import process from 'node:process';
7-
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
7+
import { VoiceOpcodes } from 'discord-api-types/voice/v8';
88
import { describe, test, expect, vitest, beforeEach } from 'vitest';
99
import {
1010
RTP_PACKET_DESKTOP,
@@ -141,36 +141,6 @@ describe('VoiceReceiver', () => {
141141
userId: '123abc',
142142
});
143143
});
144-
145-
test('CLIENT_CONNECT packet', () => {
146-
const spy = vitest.spyOn(receiver.ssrcMap, 'update');
147-
receiver['onWsPacket']({
148-
op: VoiceOpcodes.ClientConnect,
149-
d: {
150-
audio_ssrc: 123,
151-
video_ssrc: 43,
152-
user_id: '123abc',
153-
},
154-
});
155-
expect(spy).toHaveBeenCalledWith({
156-
audioSSRC: 123,
157-
videoSSRC: 43,
158-
userId: '123abc',
159-
});
160-
receiver['onWsPacket']({
161-
op: VoiceOpcodes.ClientConnect,
162-
d: {
163-
audio_ssrc: 123,
164-
video_ssrc: 0,
165-
user_id: '123abc',
166-
},
167-
});
168-
expect(spy).toHaveBeenCalledWith({
169-
audioSSRC: 123,
170-
videoSSRC: undefined,
171-
userId: '123abc',
172-
});
173-
});
174144
});
175145

176146
describe('decrypt', () => {

packages/voice/__tests__/VoiceWebSocket.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type EventEmitter, once } from 'node:events';
2-
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
2+
import { VoiceOpcodes } from 'discord-api-types/voice/v8';
33
import { describe, test, expect, beforeEach } from 'vitest';
44
import WS from 'vitest-websocket-mock';
55
import { VoiceWebSocket } from '../src/networking/VoiceWebSocket';

packages/voice/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"funding": "https://github.com/discordjs/discord.js?sponsor",
6565
"dependencies": {
6666
"@types/ws": "^8.18.1",
67-
"discord-api-types": "^0.38.1",
67+
"discord-api-types": "0.38.11-next.121fb47.1748992030",
6868
"prism-media": "^1.3.5",
6969
"tslib": "^2.8.1",
7070
"ws": "^8.18.1"
@@ -75,6 +75,7 @@
7575
"@discordjs/scripts": "workspace:^",
7676
"@favware/cliff-jumper": "^4.1.0",
7777
"@noble/ciphers": "^1.2.1",
78+
"@snazzah/davey": "^0.1.4",
7879
"@types/node": "^22.15.2",
7980
"@vitest/coverage-v8": "^3.1.1",
8081
"cross-env": "^7.0.3",

packages/voice/src/VoiceConnection.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ export class VoiceConnection extends EventEmitter {
412412
token: server.token,
413413
sessionId: state.session_id,
414414
userId: state.user_id,
415+
channelId: state.channel_id!,
415416
},
416417
Boolean(this.debug),
417418
);
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { Buffer } from 'node:buffer';
2+
import { EventEmitter } from 'node:events';
3+
import type { VoiceDavePrepareEpochData, VoiceDavePrepareTransitionData } from 'discord-api-types/voice';
4+
5+
const LIBRARY_NAME = '@snazzah/davey';
6+
let Davey: any = null;
7+
8+
// eslint-disable-next-line no-async-promise-executor
9+
export const daveLoadPromise = new Promise<void>(async (resolve) => {
10+
try {
11+
const lib = await import(LIBRARY_NAME);
12+
Davey = lib;
13+
} catch {}
14+
15+
resolve();
16+
});
17+
18+
export interface TransitionResult {
19+
success: boolean;
20+
transitionId: number;
21+
}
22+
23+
/**
24+
* The maximum DAVE protocol version supported.
25+
*/
26+
export function getMaxProtocolVersion(): number | null {
27+
return Davey?.DAVE_PROTOCOL_VERSION;
28+
}
29+
30+
export interface DAVESession extends EventEmitter {
31+
on(event: 'error', listener: (error: Error) => void): this;
32+
on(event: 'debug', listener: (message: string) => void): this;
33+
on(event: 'keyPackage', listener: (message: Buffer) => void): this;
34+
}
35+
36+
/**
37+
* Manages the DAVE protocol group session.
38+
*/
39+
export class DAVESession extends EventEmitter {
40+
/**
41+
* The channel ID represented by this session.
42+
*/
43+
public channelId: string;
44+
45+
/**
46+
* The user ID represented by this session.
47+
*/
48+
public userId: string;
49+
50+
/**
51+
* The protocol version being used.
52+
*/
53+
public protocolVersion: number;
54+
55+
/**
56+
* The pending transition.
57+
*/
58+
private pendingTransition?: VoiceDavePrepareTransitionData | undefined;
59+
60+
/**
61+
* Whether this session was downgraded previously.
62+
*/
63+
private downgraded = false;
64+
65+
/**
66+
* The underlying DAVE Session of this wrapper.
67+
*/
68+
public session: any;
69+
70+
public constructor(protocolVersion: number, userId: string, channelId: string) {
71+
if (Davey === null)
72+
throw new Error(
73+
`Cannot utilize the DAVE protocol as the @snazzah/davey package has not been installed.
74+
- Use the generateDependencyReport() function for more information.\n`,
75+
);
76+
77+
super();
78+
79+
this.protocolVersion = protocolVersion;
80+
this.userId = userId;
81+
this.channelId = channelId;
82+
}
83+
84+
/**
85+
* Re-initializes (or initializes) the underlying session.
86+
*/
87+
public reinit() {
88+
if (this.protocolVersion > 0) {
89+
if (this.session) {
90+
this.session.reinit(this.protocolVersion, this.userId, this.channelId);
91+
this.emit('debug', `Session reinitialized for protocol version ${this.protocolVersion}`);
92+
} else {
93+
this.session = new Davey.DAVESession(this.protocolVersion, this.userId, this.channelId);
94+
this.emit('debug', `Session initialized for protocol version ${this.protocolVersion}`);
95+
}
96+
97+
this.emit('keyPackage', this.session.getSerializedKeyPackage());
98+
} else if (this.session) {
99+
this.session.reset();
100+
this.session.setPassthroughMode(true, 10);
101+
this.emit('debug', 'Session reset');
102+
}
103+
}
104+
105+
/**
106+
* Set the external sender for this session.
107+
*
108+
* @param externalSender - The external sender
109+
*/
110+
public setExternalSender(externalSender: Buffer) {
111+
this.session.setExternalSender(externalSender);
112+
this.emit('debug', 'Set MLS external sender');
113+
}
114+
115+
/**
116+
* Prepare for a transition.
117+
*
118+
* @param data - The transition data
119+
* @returns Whether we should signal to the voice server that we are ready
120+
*/
121+
public prepareTransition(data: VoiceDavePrepareTransitionData) {
122+
this.emit('debug', `Preparing for DAVE transition (${data.transition_id}, v${data.protocol_version})`);
123+
this.pendingTransition = data;
124+
125+
// When the included transition ID is 0, the transition is for (re)initialization and it can be executed immediately.
126+
if (data.transition_id === 0) {
127+
this.executeTransition(data.transition_id);
128+
} else {
129+
if (data.protocol_version === 0) this.session?.setPassthroughMode(true, 120);
130+
return true;
131+
}
132+
133+
return false;
134+
}
135+
136+
/**
137+
* Execute a transition.
138+
*
139+
* @param transitionId - The transition id to execute on
140+
*/
141+
public executeTransition(transitionId: number) {
142+
this.emit('debug', `Executing DAVE transition (${transitionId})`);
143+
if (!this.pendingTransition) return;
144+
let transitioned = false;
145+
if (transitionId === this.pendingTransition.transition_id) {
146+
const oldVersion = this.protocolVersion;
147+
this.protocolVersion = this.pendingTransition.protocol_version;
148+
149+
// Handle upgrades & defer downgrades
150+
if (oldVersion !== this.protocolVersion && this.protocolVersion === 0) {
151+
this.downgraded = true;
152+
this.emit('debug', 'DAVE protocol downgraded');
153+
} else if (transitionId > 0 && this.downgraded) {
154+
this.downgraded = false;
155+
this.session?.setPassthroughMode(true, 10);
156+
this.emit('debug', 'DAVE protocol upgraded');
157+
}
158+
159+
// In the future we'd want to signal to the DAVESession to transition also, but it only supports v1 at this time
160+
transitioned = true;
161+
this.emit('debug', `DAVE transition executed (v${oldVersion} -> v${this.protocolVersion}, id: ${transitionId})`);
162+
}
163+
164+
this.pendingTransition = undefined;
165+
return transitioned;
166+
}
167+
168+
/**
169+
* Prepare for a new epoch.
170+
*
171+
* @param data - The epoch data
172+
*/
173+
public prepareEpoch(data: VoiceDavePrepareEpochData) {
174+
this.emit('debug', `Preparing for DAVE epoch (${data.epoch})`);
175+
if (data.epoch === 1) {
176+
this.protocolVersion = data.protocol_version;
177+
this.reinit();
178+
}
179+
}
180+
181+
/**
182+
* Processes proposals from the MLS group.
183+
*
184+
* @param payload - The proposals or proposal refs buffer
185+
* @returns The payload to send back to the voice server, if there is one
186+
*/
187+
public processProposals(payload: Buffer): Buffer | undefined {
188+
const optype = payload.readUInt8(0);
189+
// TODO store clients connected and pass in here
190+
const { commit, welcome } = this.session.processProposals(optype, payload.subarray(1));
191+
this.emit('debug', 'MLS proposals processed');
192+
if (!commit) return;
193+
return welcome ? Buffer.concat([commit, welcome]) : commit;
194+
}
195+
196+
/**
197+
* Processes a commit from the MLS group.
198+
*
199+
* @param payload - The payload
200+
* @returns The transaction ID and whether it was successful
201+
*/
202+
public processCommit(payload: Buffer): TransitionResult {
203+
const transitionId = payload.readUInt16BE(0);
204+
try {
205+
this.session.processCommit(payload.subarray(2));
206+
this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion };
207+
this.emit('debug', `MLS commit processed (transition id: ${transitionId})`);
208+
return { transitionId, success: true };
209+
} catch {
210+
// TODO
211+
// this.emit("warn", `MLS commit errored: ${e}`);
212+
return { transitionId, success: false };
213+
}
214+
}
215+
216+
/**
217+
* Processes a welcome from the MLS group.
218+
*
219+
* @param payload - The payload
220+
* @returns The transaction ID and whether it was successful
221+
*/
222+
public processWelcome(payload: Buffer): TransitionResult {
223+
const transitionId = payload.readUInt16BE(0);
224+
try {
225+
this.session.processWelcome(payload.subarray(2));
226+
this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion };
227+
this.emit('debug', `MLS welcome processed (transition id: ${transitionId})`);
228+
return { transitionId, success: true };
229+
} catch {
230+
// TODO
231+
// this.emit("warn", `MLS welcome errored: ${e}`);
232+
return { transitionId, success: false };
233+
}
234+
}
235+
236+
/**
237+
* Resets the session.
238+
*/
239+
public destroy() {
240+
try {
241+
this.session.reset();
242+
} catch {}
243+
}
244+
}

0 commit comments

Comments
 (0)