Skip to content

Commit 9f8b9b1

Browse files
nyapatJiralitevladfrangu
authored
feat(voice)!: add new encryption methods, remove old methods (#10451)
BREAKING CHANGE: This library no longer supports using `tweetnacl` as an encryption library due to Discord deprecating the algorithms that `tweetnacl` helped us support (read more [here](https://discord.com/developers/docs/change-log#voice-encryption-modes)). Please migrate to one of: `sodium-native`, `sodium`, `@stablelib/xchacha20poly1305`, `@noble/ciphers` or `libsodium-wrappers` unless your system supports `aes-256-gcm` (verify by running `require('node:crypto').getCiphers().includes('aes-256-gcm')`). --------- Co-authored-by: Jiralite <[email protected]> Co-authored-by: Vlad Frangu <[email protected]>
1 parent 51a017a commit 9f8b9b1

File tree

11 files changed

+355
-200
lines changed

11 files changed

+355
-200
lines changed

packages/voice/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ try installing another.
5353

5454
- `sodium-native`: ^3.3.0
5555
- `sodium`: ^3.0.2
56-
- `tweetnacl`: ^1.0.3
56+
- `@stablelib/xchacha20poly1305`: ^2.0.0
57+
- `@noble/ciphers`: ^1.0.0
5758
- `libsodium-wrappers`: ^0.7.9
5859

5960
**Opus Libraries (npm install):**

packages/voice/__mocks__/rtp.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { Buffer } from 'node:buffer';
22

3+
// The following constants are silence packets collected from various platforms because Discord did not previously send header extensions
4+
// The header extension (extra data in decrypted vs opusFrame) can be detected in the position of {encrypted.subarray(12,14)} if it is equal to 0xbe,0xde
5+
// The header extension length will then follow as an integer and can be removed from the decrypted data (see ../src/receive/VoiceReceiver.ts:parsePacket)
6+
37
export const RTP_PACKET_DESKTOP = {
48
ssrc: 341_124,
59
packet: Buffer.from([
6-
0x90, 0x78, 0x27, 0xe9, 0xf7, 0xcb, 0xbc, 0xd1, 0x0, 0x5, 0x34, 0x84, 0x8a, 0xbb, 0xe2, 0x97, 0x21, 0x9f, 0x1f,
7-
0x67, 0xcd, 0x17, 0x91, 0x56, 0x43, 0xa0, 0x98, 0xfd, 0xa9, 0x25, 0x81, 0x63, 0x13, 0xb4, 0x1e, 0xae, 0x88, 0xe4,
8-
0x0, 0xed, 0x0, 0x0, 0x0,
10+
0x90, 0x78, 0x27, 0xe9, 0xf7, 0xcb, 0xbc, 0xd1, 0x0, 0x5, 0x34, 0x84, 0xbe, 0xde, 0x0, 0x1, 0x8a, 0xbb, 0xe2, 0x97,
11+
0x21, 0x9f, 0x1f, 0x67, 0xcd, 0x17, 0x91, 0x56, 0x43, 0xa0, 0x98, 0xfd, 0xa9, 0x25, 0x81, 0x63, 0x13, 0xb4, 0x1e,
12+
0xae, 0x88, 0xe4, 0x0, 0xed, 0x0, 0x0, 0x0,
913
]),
10-
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
14+
decrypted: Buffer.from([0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
1115
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
1216
};
1317

@@ -24,10 +28,43 @@ export const RTP_PACKET_CHROME = {
2428
export const RTP_PACKET_ANDROID = {
2529
ssrc: 172_596,
2630
packet: Buffer.from([
27-
0x90, 0x78, 0x39, 0xd0, 0xe0, 0x59, 0xf5, 0x47, 0x0, 0x2, 0xa2, 0x34, 0x12, 0x6d, 0x87, 0x56, 0x25, 0xc8, 0x3e,
28-
0x96, 0xc0, 0x71, 0x9a, 0x1, 0x83, 0xe, 0x1, 0x62, 0x91, 0x95, 0x1f, 0x76, 0x57, 0x15, 0x41, 0xab, 0xee, 0x5b, 0xac,
29-
0x8b, 0x0, 0x0, 0x0,
31+
0x90, 0x78, 0x39, 0xd0, 0xe0, 0x59, 0xf5, 0x47, 0x0, 0x2, 0xa2, 0x34, 0xbe, 0xde, 0x0, 0x1, 0x12, 0x6d, 0x87, 0x56,
32+
0x25, 0xc8, 0x3e, 0x96, 0xc0, 0x71, 0x9a, 0x1, 0x83, 0xe, 0x1, 0x62, 0x91, 0x95, 0x1f, 0x76, 0x57, 0x15, 0x41, 0xab,
33+
0xee, 0x5b, 0xac, 0x8b, 0x0, 0x0, 0x0,
3034
]),
31-
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
35+
decrypted: Buffer.from([0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
3236
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
3337
};
38+
39+
export const XCHACHA20_SAMPLE = {
40+
encrypted: Buffer.from([
41+
144, 120, 86, 102, 191, 243, 52, 48, 0, 0, 191, 25, 190, 222, 0, 2, 211, 29, 103, 76, 73, 102, 37, 162, 65, 41, 39,
42+
252, 26, 85, 90, 228, 241, 169, 112, 65, 95, 183, 236, 4, 87, 207, 215, 195, 180, 39, 60, 224, 221, 89, 32, 187,
43+
208, 228, 145, 252, 132, 189, 103, 208, 180, 183, 217, 50, 70, 86, 20, 28, 142, 66, 251, 122, 214, 17, 7, 173, 203,
44+
117, 148, 232, 118, 103, 8, 136, 227, 136, 249, 243, 135, 41, 31, 103, 146, 15, 1, 0, 0,
45+
]),
46+
key: new Uint8Array([
47+
105, 217, 109, 27, 247, 101, 71, 49, 71, 151, 172, 85, 91, 11, 201, 23, 43, 242, 147, 81, 96, 60, 157, 50, 63, 200,
48+
133, 174, 108, 144, 251, 110,
49+
]),
50+
51+
decrypted: Buffer.from([
52+
0x32, 0x64, 0xe6, 0x62, 0x10, 0xe3, 0x90, 0x02, 0x78, 0x07, 0xd6, 0x2f, 0x52, 0x23, 0x20, 0x9a, 0xab, 0x2c, 0xcc,
53+
0x1c, 0x88, 0x8e, 0xcb, 0xd9, 0x4d, 0xe5, 0x33, 0x7a, 0x4b, 0x2b, 0xed, 0xa7, 0xaf, 0x5f, 0x8d, 0xb2, 0x59, 0x99,
54+
0x75, 0x36, 0xf2, 0x88, 0xf5, 0xc7, 0x9f, 0x47, 0xaf, 0x92, 0x5a, 0x96, 0x3b, 0xd8, 0x9f, 0x3a, 0xb4, 0x13, 0xce,
55+
0x2f, 0xae, 0x0a, 0x37,
56+
]),
57+
};
58+
59+
export const AES256GCM_SAMPLE = {
60+
encrypted: Buffer.from([
61+
128, 120, 163, 156, 159, 11, 131, 240, 0, 0, 197, 183, 175, 91, 102, 101, 195, 6, 200, 143, 117, 72, 108, 44, 165,
62+
123, 121, 49, 111, 38, 3, 0, 0, 0, 90,
63+
]),
64+
key: Buffer.from([
65+
109, 77, 195, 27, 111, 50, 231, 84, 179, 255, 217, 217, 34, 227, 19, 106, 195, 20, 150, 237, 38, 4, 101, 210, 5, 90,
66+
8, 241, 58, 223, 24, 24,
67+
]),
68+
69+
decrypted: Buffer.from([0xf8, 0xff, 0xfe]),
70+
};
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { test, expect, vitest } from 'vitest';
2-
import { methods } from '../src/util/Secretbox';
2+
import { methods, secretboxLoadPromise } from '../src/util/Secretbox';
33

4-
vitest.mock('tweetnacl');
4+
vitest.mock('@noble/ciphers/chacha');
55

6-
test('Does not throw error with a package installed', () => {
7-
// @ts-expect-error We are testing
8-
expect(() => methods.open()).toThrow(TypeError);
6+
// TODO: what is this even testing exactly?
7+
test.skip('Does not throw error with a package installed', async () => {
8+
// The async loop in Secretbox will not have finished importing unless we wait
9+
await secretboxLoadPromise;
10+
11+
expect(() => methods.crypto_aead_xchacha20poly1305_ietf_decrypt()).not.toThrowError();
912
});

packages/voice/__tests__/VoiceReceiver.test.ts

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { once } from 'node:events';
66
import process from 'node:process';
77
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
88
import { describe, test, expect, vitest, beforeEach } from 'vitest';
9-
import { RTP_PACKET_DESKTOP, RTP_PACKET_CHROME, RTP_PACKET_ANDROID } from '../__mocks__/rtp';
9+
import {
10+
RTP_PACKET_DESKTOP,
11+
RTP_PACKET_CHROME,
12+
RTP_PACKET_ANDROID,
13+
XCHACHA20_SAMPLE,
14+
AES256GCM_SAMPLE,
15+
} from '../__mocks__/rtp';
1016
import { VoiceConnection, VoiceConnectionStatus } from '../src/VoiceConnection';
1117
import { VoiceReceiver } from '../src/receive/VoiceReceiver';
1218
import { methods } from '../src/util/Secretbox';
@@ -22,10 +28,6 @@ vitest.mock('../src/VoiceConnection', async (importOriginal) => {
2228

2329
vitest.mock('../src/receive/SSRCMap');
2430

25-
const openSpy = vitest.spyOn(methods, 'open');
26-
27-
openSpy.mockImplementation((buffer) => buffer);
28-
2931
async function nextTick() {
3032
// eslint-disable-next-line no-promise-executor-return
3133
return new Promise((resolve) => process.nextTick(resolve));
@@ -62,7 +64,7 @@ describe('VoiceReceiver', () => {
6264
['RTP Packet Desktop', RTP_PACKET_DESKTOP],
6365
['RTP Packet Chrome', RTP_PACKET_CHROME],
6466
['RTP Packet Android', RTP_PACKET_ANDROID],
65-
])('onUdpMessage: %s', async (testName, RTP_PACKET) => {
67+
])('onUdpMessage: decrypt from %s', async (testName, RTP_PACKET) => {
6668
receiver['decrypt'] = vitest.fn().mockImplementationOnce(() => RTP_PACKET.decrypted);
6769

6870
const spy = vitest.spyOn(receiver.ssrcMap, 'get');
@@ -174,47 +176,42 @@ describe('VoiceReceiver', () => {
174176
describe('decrypt', () => {
175177
const secretKey = new Uint8Array([1, 2, 3, 4]);
176178

177-
beforeEach(() => {
178-
openSpy.mockClear();
179-
});
179+
test('decrypt: aead_xchacha20_poly1305_rtpsize', () => {
180+
const nonceSpace = Buffer.alloc(24);
180181

181-
test('decrypt: xsalsa20_poly1305_lite', () => {
182-
// Arrange
183-
const buffer = range(1, 32);
184-
const nonce = Buffer.alloc(4);
182+
const decrypted = receiver['decrypt'](
183+
XCHACHA20_SAMPLE.encrypted,
184+
'aead_xchacha20_poly1305_rtpsize',
185+
nonceSpace,
186+
XCHACHA20_SAMPLE.key,
187+
);
185188

186-
// Act
187-
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_lite', nonce, secretKey);
189+
const expectedNonce = Buffer.concat([
190+
XCHACHA20_SAMPLE.encrypted.slice(XCHACHA20_SAMPLE.encrypted.length - 4),
191+
Buffer.alloc(20),
192+
]);
188193

189-
// Assert
190-
expect(nonce.equals(range(29, 32))).toEqual(true);
191-
expect(decrypted!.equals(range(13, 28))).toEqual(true);
194+
expect(nonceSpace.equals(expectedNonce)).toEqual(true);
195+
expect(decrypted.equals(XCHACHA20_SAMPLE.decrypted)).toEqual(true);
192196
});
193197

194-
test('decrypt: xsalsa20_poly1305_suffix', () => {
195-
// Arrange
196-
const buffer = range(1, 64);
197-
const nonce = Buffer.alloc(24);
198-
199-
// Act
200-
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_suffix', nonce, secretKey);
201-
202-
// Assert
203-
expect(nonce.equals(range(41, 64))).toEqual(true);
204-
expect(decrypted!.equals(range(13, 40))).toEqual(true);
205-
});
198+
test('decrypt: aead_aes256gcm_rtpsize', () => {
199+
const nonceSpace = Buffer.alloc(12);
206200

207-
test('decrypt: xsalsa20_poly1305', () => {
208-
// Arrange
209-
const buffer = range(1, 64);
210-
const nonce = Buffer.alloc(12);
201+
const decrypted = receiver['decrypt'](
202+
AES256GCM_SAMPLE.encrypted,
203+
'aead_aes256_gcm_rtpsize',
204+
nonceSpace,
205+
AES256GCM_SAMPLE.key,
206+
);
211207

212-
// Act
213-
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305', nonce, secretKey);
208+
const expectedNonce = Buffer.concat([
209+
AES256GCM_SAMPLE.encrypted.subarray(AES256GCM_SAMPLE.encrypted.length - 4),
210+
Buffer.alloc(8),
211+
]);
214212

215-
// Assert
216-
expect(nonce.equals(range(1, 12))).toEqual(true);
217-
expect(decrypted!.equals(range(13, 64))).toEqual(true);
213+
expect(nonceSpace.equals(expectedNonce)).toEqual(true);
214+
expect(decrypted.equals(AES256GCM_SAMPLE.decrypted)).toEqual(true);
218215
});
219216
});
220217
});

packages/voice/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"@discordjs/opus": "^0.9.0",
7575
"@discordjs/scripts": "workspace:^",
7676
"@favware/cliff-jumper": "^4.1.0",
77+
"@noble/ciphers": "^1.0.0",
7778
"@types/node": "18.19.45",
7879
"@vitest/coverage-v8": "2.0.5",
7980
"cross-env": "^7.0.3",
@@ -84,7 +85,6 @@
8485
"prettier": "^3.3.3",
8586
"tsup": "^8.2.4",
8687
"turbo": "^2.0.14",
87-
"tweetnacl": "^1.0.3",
8888
"typescript": "~5.5.4",
8989
"vitest": "^2.0.5",
9090
"vitest-websocket-mock": "^0.3.0"

packages/voice/scripts/postbuild.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises';
33
const data = await readFile('./dist/index.mjs', 'utf8');
44
await writeFile(
55
'./dist/index.mjs',
6-
`import{createRequire as topLevelCreateRequire}from"module";const require=topLevelCreateRequire(import.meta.url);${data}`,
6+
`import { createRequire as topLevelCreateRequire } from "module";
7+
const require = topLevelCreateRequire(import.meta.url);
8+
${data}`,
79
);

packages/voice/src/networking/Networking.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/* eslint-disable id-length */
33
/* eslint-disable @typescript-eslint/unbound-method */
44
import { Buffer } from 'node:buffer';
5+
import crypto from 'node:crypto';
56
import { EventEmitter } from 'node:events';
67
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
78
import type { CloseEvent } from 'ws';
@@ -15,7 +16,12 @@ const CHANNELS = 2;
1516
const TIMESTAMP_INC = (48_000 / 100) * CHANNELS;
1617
const MAX_NONCE_SIZE = 2 ** 32 - 1;
1718

18-
export const SUPPORTED_ENCRYPTION_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
19+
export const SUPPORTED_ENCRYPTION_MODES = ['aead_xchacha20_poly1305_rtpsize'];
20+
21+
// Just in case there's some system that doesn't come with aes-256-gcm, conditionally add it as supported
22+
if (crypto.getCiphers().includes('aes-256-gcm')) {
23+
SUPPORTED_ENCRYPTION_MODES.unshift('aead_aes256_gcm_rtpsize');
24+
}
1925

2026
/**
2127
* The different statuses that a networking instance can hold. The order
@@ -187,6 +193,7 @@ function stringifyState(state: NetworkingState) {
187193
function chooseEncryptionMode(options: string[]): string {
188194
const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option));
189195
if (!option) {
196+
// This should only ever happen if the gateway does not give us any encryption modes we support.
190197
throw new Error(`No compatible encryption modes. Available include: ${options.join(', ')}`);
191198
}
192199

@@ -442,7 +449,7 @@ export class Networking extends EventEmitter {
442449
sequence: randomNBit(16),
443450
timestamp: randomNBit(32),
444451
nonce: 0,
445-
nonceBuffer: Buffer.alloc(24),
452+
nonceBuffer: encryptionMode === 'aead_aes256_gcm_rtpsize' ? Buffer.alloc(12) : Buffer.alloc(24),
446453
speaking: false,
447454
packetsPlayed: 0,
448455
},
@@ -554,18 +561,18 @@ export class Networking extends EventEmitter {
554561
* @param connectionData - The current connection data of the instance
555562
*/
556563
private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) {
557-
const packetBuffer = Buffer.alloc(12);
558-
packetBuffer[0] = 0x80;
559-
packetBuffer[1] = 0x78;
564+
const rtpHeader = Buffer.alloc(12);
565+
rtpHeader[0] = 0x80;
566+
rtpHeader[1] = 0x78;
560567

561568
const { sequence, timestamp, ssrc } = connectionData;
562569

563-
packetBuffer.writeUIntBE(sequence, 2, 2);
564-
packetBuffer.writeUIntBE(timestamp, 4, 4);
565-
packetBuffer.writeUIntBE(ssrc, 8, 4);
570+
rtpHeader.writeUIntBE(sequence, 2, 2);
571+
rtpHeader.writeUIntBE(timestamp, 4, 4);
572+
rtpHeader.writeUIntBE(ssrc, 8, 4);
566573

567-
packetBuffer.copy(nonce, 0, 0, 12);
568-
return Buffer.concat([packetBuffer, ...this.encryptOpusPacket(opusPacket, connectionData)]);
574+
rtpHeader.copy(nonce, 0, 0, 12);
575+
return Buffer.concat([rtpHeader, ...this.encryptOpusPacket(opusPacket, connectionData, rtpHeader)]);
569576
}
570577

571578
/**
@@ -574,22 +581,43 @@ export class Networking extends EventEmitter {
574581
* @param opusPacket - The Opus packet to encrypt
575582
* @param connectionData - The current connection data of the instance
576583
*/
577-
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData) {
584+
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData, additionalData: Buffer) {
578585
const { secretKey, encryptionMode } = connectionData;
579586

580-
if (encryptionMode === 'xsalsa20_poly1305_lite') {
581-
connectionData.nonce++;
582-
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
583-
connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0);
584-
return [
585-
secretbox.methods.close(opusPacket, connectionData.nonceBuffer, secretKey),
586-
connectionData.nonceBuffer.slice(0, 4),
587-
];
588-
} else if (encryptionMode === 'xsalsa20_poly1305_suffix') {
589-
const random = secretbox.methods.random(24, connectionData.nonceBuffer);
590-
return [secretbox.methods.close(opusPacket, random, secretKey), random];
591-
}
587+
// Both supported encryption methods want the nonce to be an incremental integer
588+
connectionData.nonce++;
589+
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
590+
connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0);
591+
592+
// 4 extra bytes of padding on the end of the encrypted packet
593+
const noncePadding = connectionData.nonceBuffer.subarray(0, 4);
594+
595+
let encrypted;
596+
switch (encryptionMode) {
597+
case 'aead_aes256_gcm_rtpsize': {
598+
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, connectionData.nonceBuffer);
599+
cipher.setAAD(additionalData);
592600

593-
return [secretbox.methods.close(opusPacket, nonce, secretKey)];
601+
encrypted = Buffer.concat([cipher.update(opusPacket), cipher.final(), cipher.getAuthTag()]);
602+
603+
return [encrypted, noncePadding];
604+
}
605+
606+
case 'aead_xchacha20_poly1305_rtpsize': {
607+
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
608+
opusPacket,
609+
additionalData,
610+
connectionData.nonceBuffer,
611+
secretKey,
612+
);
613+
614+
return [encrypted, noncePadding];
615+
}
616+
617+
default: {
618+
// This should never happen. Our encryption mode is chosen from a list given to us by the gateway and checked with the ones we support.
619+
throw new RangeError(`Unsupported encryption method: ${encryptionMode}`);
620+
}
621+
}
594622
}
595623
}

0 commit comments

Comments
 (0)