Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
400a4be
feat(voice): implement DAVE E2EE encryption
Snazzah Jun 4, 2025
ec6b8b4
chore(voice): update dependencies
Snazzah Jun 4, 2025
0c716b5
chore(voice): update debug logs and dependency report
Snazzah Jun 4, 2025
4952ef6
feat(voice): emit and propogate DAVESession errors
Snazzah Jun 4, 2025
9571449
chore(voice): export dave session things
Snazzah Jun 4, 2025
6c2d9be
chore(voice): move expiry numbers to consts
Snazzah Jun 4, 2025
5461e94
feat(voice): keep track of and pass connected client IDs
Snazzah Jun 4, 2025
d44b76a
fix(voice): dont set initial transitions as pending
Snazzah Jun 4, 2025
99e10b4
feat(voice): dave encryption
Snazzah Jun 4, 2025
c196909
chore(voice): directly reference package name in import
Snazzah Jun 4, 2025
c925557
feat(voice): dave decryption
Snazzah Jun 4, 2025
bd29b62
chore(deps): update @snazzah/davey
Snazzah Jun 5, 2025
04dac3a
fix(voice): handle decryption failure tolerance
Snazzah Jun 5, 2025
5feb52c
fix(voice): move and update decryption failure logic to DAVESession
Snazzah Jun 6, 2025
890c20d
feat(voice): propogate voice privacy code
Snazzah Jun 8, 2025
f8fa827
fix(voice): actually send a transition ready when ready
Snazzah Jun 8, 2025
333d50e
feat(voice): propogate transitions and verification code function
Snazzah Jun 8, 2025
fb7c39a
feat(voice): add dave options
Snazzah Jun 8, 2025
4102bac
Merge branch 'main' into feat/dave-protocol
Snazzah Jun 8, 2025
d6eb94e
chore: resolve format change requests
Snazzah Jun 9, 2025
22f1f06
chore: emit debug messages on bad transitions
Snazzah Jun 9, 2025
f054fc6
chore: downgrade commit/welcome errors as debug messages
Snazzah Jun 9, 2025
b74395c
chore: resolve formatting change requests
Snazzah Jun 9, 2025
c1f3cc6
chore: update davey dependency
Snazzah Jun 10, 2025
00b8717
chore: add types for underlying dave session
Snazzah Jun 10, 2025
e58eb4f
Merge branch 'main' into feat/dave-protocol
Snazzah Jun 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 1 addition & 31 deletions packages/voice/__tests__/VoiceReceiver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/voice/__tests__/VoiceWebSocket.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion packages/voice/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@types/ws": "^8.18.1",
"discord-api-types": "^0.38.1",
"discord-api-types": "^0.38.11",
"prism-media": "^1.3.5",
"tslib": "^2.8.1",
"ws": "^8.18.1"
Expand All @@ -75,6 +75,7 @@
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@noble/ciphers": "^1.2.1",
"@snazzah/davey": "^0.1.4",
"@types/node": "^22.15.2",
"@vitest/coverage-v8": "^3.1.1",
"cross-env": "^7.0.3",
Expand Down
1 change: 1 addition & 0 deletions packages/voice/src/VoiceConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export class VoiceConnection extends EventEmitter {
token: server.token,
sessionId: state.session_id,
userId: state.user_id,
channelId: state.channel_id!,
},
Boolean(this.debug),
);
Expand Down
244 changes: 244 additions & 0 deletions packages/voice/src/networking/DAVESession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { Buffer } from 'node:buffer';
import { EventEmitter } from 'node:events';
import type { VoiceDavePrepareEpochData, VoiceDavePrepareTransitionData } from 'discord-api-types/voice/v8';

const LIBRARY_NAME = '@snazzah/davey';
let Davey: any = null;

// eslint-disable-next-line no-async-promise-executor
export const daveLoadPromise = new Promise<void>(async (resolve) => {
try {
const lib = await import(LIBRARY_NAME);
Davey = lib;
} catch {}

resolve();
});

export interface TransitionResult {
success: boolean;
transitionId: number;
}

/**
* 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;
}

/**
* 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 pending transition.
*/
private pendingTransition?: VoiceDavePrepareTransitionData | undefined;

/**
* Whether this session was downgraded previously.
*/
private downgraded = false;

/**
* The underlying DAVE Session of this wrapper.
*/
public session: any;

public constructor(protocolVersion: number, userId: string, channelId: string) {
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;
}

/**
* 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, 10);
this.emit('debug', 'Session reset');
}
}

/**
* Set the external sender for this session.
*
* @param externalSender - The external sender
*/
public setExternalSender(externalSender: Buffer) {
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, 120);
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) 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, 10);
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.emit('debug', `Transition executed (v${oldVersion} -> v${this.protocolVersion}, id: ${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();
}
}

/**
* Processes proposals from the MLS group.
*
* @param payload - The proposals or proposal refs buffer
* @returns The payload to send back to the voice server, if there is one
*/
public processProposals(payload: Buffer): Buffer | undefined {
const optype = payload.readUInt8(0);
// TODO store clients connected and pass in here
const { commit, welcome } = this.session.processProposals(optype, payload.subarray(1));
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 {
const transitionId = payload.readUInt16BE(0);
try {
this.session.processCommit(payload.subarray(2));
this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion };
this.emit('debug', `MLS commit processed (transition id: ${transitionId})`);
return { transitionId, success: true };
} catch {
// TODO
// this.emit("warn", `MLS commit errored: ${e}`);
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 {
const transitionId = payload.readUInt16BE(0);
try {
this.session.processWelcome(payload.subarray(2));
this.pendingTransition = { transition_id: transitionId, protocol_version: this.protocolVersion };
this.emit('debug', `MLS welcome processed (transition id: ${transitionId})`);
return { transitionId, success: true };
} catch {
// TODO
// this.emit("warn", `MLS welcome errored: ${e}`);
return { transitionId, success: false };
}
}

/**
* Resets the session.
*/
public destroy() {
try {
this.session.reset();
} catch {}
}
}
Loading