Skip to content

feat(voice)!: add new encryption methods, remove old methods #10451

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

Merged
merged 20 commits into from
Nov 17, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
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
2 changes: 1 addition & 1 deletion packages/voice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ try installing another.

- `sodium-native`: ^3.3.0
- `sodium`: ^3.0.2
- `tweetnacl`: ^1.0.3
- `@stablelib/xchacha20poly1305`: ^2.0.0
- `libsodium-wrappers`: ^0.7.9

**Opus Libraries (npm install):**
Expand Down
53 changes: 45 additions & 8 deletions packages/voice/__mocks__/rtp.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Buffer } from 'node:buffer';

// The following constants are silence packets collected from various platforms because Discord did not previously send header extensions
// 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
// The header extension length will then follow as an integer and can be removed from the decrypted data (see ../src/receive/VoiceReceiver.ts:parsePacket)

export const RTP_PACKET_DESKTOP = {
ssrc: 341_124,
packet: Buffer.from([
0x90, 0x78, 0x27, 0xe9, 0xf7, 0xcb, 0xbc, 0xd1, 0x0, 0x5, 0x34, 0x84, 0x8a, 0xbb, 0xe2, 0x97, 0x21, 0x9f, 0x1f,
0x67, 0xcd, 0x17, 0x91, 0x56, 0x43, 0xa0, 0x98, 0xfd, 0xa9, 0x25, 0x81, 0x63, 0x13, 0xb4, 0x1e, 0xae, 0x88, 0xe4,
0x0, 0xed, 0x0, 0x0, 0x0,
0x90, 0x78, 0x27, 0xe9, 0xf7, 0xcb, 0xbc, 0xd1, 0x0, 0x5, 0x34, 0x84, 0xbe, 0xde, 0x0, 0x1, 0x8a, 0xbb, 0xe2, 0x97,
0x21, 0x9f, 0x1f, 0x67, 0xcd, 0x17, 0x91, 0x56, 0x43, 0xa0, 0x98, 0xfd, 0xa9, 0x25, 0x81, 0x63, 0x13, 0xb4, 0x1e,
0xae, 0x88, 0xe4, 0x0, 0xed, 0x0, 0x0, 0x0,
]),
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
decrypted: Buffer.from([0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
};

Expand All @@ -24,10 +28,43 @@ export const RTP_PACKET_CHROME = {
export const RTP_PACKET_ANDROID = {
ssrc: 172_596,
packet: Buffer.from([
0x90, 0x78, 0x39, 0xd0, 0xe0, 0x59, 0xf5, 0x47, 0x0, 0x2, 0xa2, 0x34, 0x12, 0x6d, 0x87, 0x56, 0x25, 0xc8, 0x3e,
0x96, 0xc0, 0x71, 0x9a, 0x1, 0x83, 0xe, 0x1, 0x62, 0x91, 0x95, 0x1f, 0x76, 0x57, 0x15, 0x41, 0xab, 0xee, 0x5b, 0xac,
0x8b, 0x0, 0x0, 0x0,
0x90, 0x78, 0x39, 0xd0, 0xe0, 0x59, 0xf5, 0x47, 0x0, 0x2, 0xa2, 0x34, 0xbe, 0xde, 0x0, 0x1, 0x12, 0x6d, 0x87, 0x56,
0x25, 0xc8, 0x3e, 0x96, 0xc0, 0x71, 0x9a, 0x1, 0x83, 0xe, 0x1, 0x62, 0x91, 0x95, 0x1f, 0x76, 0x57, 0x15, 0x41, 0xab,
0xee, 0x5b, 0xac, 0x8b, 0x0, 0x0, 0x0,
]),
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
decrypted: Buffer.from([0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
};

export const XCHACHA20_SAMPLE = {
encrypted: Buffer.from([
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,
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,
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,
117, 148, 232, 118, 103, 8, 136, 227, 136, 249, 243, 135, 41, 31, 103, 146, 15, 1, 0, 0,
]),
key: new Uint8Array([
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,
133, 174, 108, 144, 251, 110,
]),

decrypted: Buffer.from([
0x32, 0x64, 0xe6, 0x62, 0x10, 0xe3, 0x90, 0x02, 0x78, 0x07, 0xd6, 0x2f, 0x52, 0x23, 0x20, 0x9a, 0xab, 0x2c, 0xcc,
0x1c, 0x88, 0x8e, 0xcb, 0xd9, 0x4d, 0xe5, 0x33, 0x7a, 0x4b, 0x2b, 0xed, 0xa7, 0xaf, 0x5f, 0x8d, 0xb2, 0x59, 0x99,
0x75, 0x36, 0xf2, 0x88, 0xf5, 0xc7, 0x9f, 0x47, 0xaf, 0x92, 0x5a, 0x96, 0x3b, 0xd8, 0x9f, 0x3a, 0xb4, 0x13, 0xce,
0x2f, 0xae, 0x0a, 0x37,
]),
};

export const AES256GCM_SAMPLE = {
encrypted: Buffer.from([
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,
123, 121, 49, 111, 38, 3, 0, 0, 0, 90,
]),
key: Buffer.from([
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,
8, 241, 58, 223, 24, 24,
]),

decrypted: Buffer.from([0xf8, 0xff, 0xfe]),
};
15 changes: 11 additions & 4 deletions packages/voice/__tests__/Secretbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { test, expect, vitest } from 'vitest';
import { methods } from '../src/util/Secretbox';

vitest.mock('tweetnacl');
async function wait(ms: number) {
/* eslint-disable no-promise-executor-return */
return new Promise((resolve) => setTimeout(resolve, ms));
}

test('Does not throw error with a package installed', () => {
// @ts-expect-error We are testing
expect(() => methods.open()).toThrow(TypeError);
vitest.mock('@stablelib/xchacha20poly1305');

test('Does not throw error with a package installed', async () => {
// The async loop in Secretbox will not have finished importing unless we wait
await wait(100);

expect(() => methods.crypto_aead_xchacha20poly1305_ietf_decrypt()).not.toThrowError();
});
75 changes: 36 additions & 39 deletions packages/voice/__tests__/VoiceReceiver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { once } from 'node:events';
import process from 'node:process';
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import { describe, test, expect, vitest, beforeEach } from 'vitest';
import { RTP_PACKET_DESKTOP, RTP_PACKET_CHROME, RTP_PACKET_ANDROID } from '../__mocks__/rtp';
import {
RTP_PACKET_DESKTOP,
RTP_PACKET_CHROME,
RTP_PACKET_ANDROID,
XCHACHA20_SAMPLE,
AES256GCM_SAMPLE,
} from '../__mocks__/rtp';
import { VoiceConnection, VoiceConnectionStatus } from '../src/VoiceConnection';
import { VoiceReceiver } from '../src/receive/VoiceReceiver';
import { methods } from '../src/util/Secretbox';
Expand All @@ -22,10 +28,6 @@ vitest.mock('../src/VoiceConnection', async (importOriginal) => {

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

const openSpy = vitest.spyOn(methods, 'open');

openSpy.mockImplementation((buffer) => buffer);

async function nextTick() {
// eslint-disable-next-line no-promise-executor-return
return new Promise((resolve) => process.nextTick(resolve));
Expand Down Expand Up @@ -62,7 +64,7 @@ describe('VoiceReceiver', () => {
['RTP Packet Desktop', RTP_PACKET_DESKTOP],
['RTP Packet Chrome', RTP_PACKET_CHROME],
['RTP Packet Android', RTP_PACKET_ANDROID],
])('onUdpMessage: %s', async (testName, RTP_PACKET) => {
])('onUdpMessage: decrypt from %s', async (testName, RTP_PACKET) => {
receiver['decrypt'] = vitest.fn().mockImplementationOnce(() => RTP_PACKET.decrypted);

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

beforeEach(() => {
openSpy.mockClear();
});
test('decrypt: aead_xchacha20_poly1305_rtpsize', () => {
const nonceSpace = Buffer.alloc(24);

test('decrypt: xsalsa20_poly1305_lite', () => {
// Arrange
const buffer = range(1, 32);
const nonce = Buffer.alloc(4);
const decrypted = receiver['decrypt'](
XCHACHA20_SAMPLE.encrypted,
'aead_xchacha20_poly1305_rtpsize',
nonceSpace,
XCHACHA20_SAMPLE.key,
);

// Act
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_lite', nonce, secretKey);
const expectedNonce = Buffer.concat([
XCHACHA20_SAMPLE.encrypted.slice(XCHACHA20_SAMPLE.encrypted.length - 4),
Buffer.alloc(20),
]);

// Assert
expect(nonce.equals(range(29, 32))).toEqual(true);
expect(decrypted!.equals(range(13, 28))).toEqual(true);
expect(nonceSpace.equals(expectedNonce)).toEqual(true);
expect(decrypted.equals(XCHACHA20_SAMPLE.decrypted)).toEqual(true);
});

test('decrypt: xsalsa20_poly1305_suffix', () => {
// Arrange
const buffer = range(1, 64);
const nonce = Buffer.alloc(24);

// Act
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_suffix', nonce, secretKey);

// Assert
expect(nonce.equals(range(41, 64))).toEqual(true);
expect(decrypted!.equals(range(13, 40))).toEqual(true);
});
test('decrypt: aead_aes256gcm_rtpsize', () => {
const nonceSpace = Buffer.alloc(12);

test('decrypt: xsalsa20_poly1305', () => {
// Arrange
const buffer = range(1, 64);
const nonce = Buffer.alloc(12);
const decrypted = receiver['decrypt'](
AES256GCM_SAMPLE.encrypted,
'aead_aes256_gcm_rtpsize',
nonceSpace,
AES256GCM_SAMPLE.key,
);

// Act
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305', nonce, secretKey);
const expectedNonce = Buffer.concat([
AES256GCM_SAMPLE.encrypted.subarray(AES256GCM_SAMPLE.encrypted.length - 4),
Buffer.alloc(8),
]);

// Assert
expect(nonce.equals(range(1, 12))).toEqual(true);
expect(decrypted!.equals(range(13, 64))).toEqual(true);
expect(nonceSpace.equals(expectedNonce)).toEqual(true);
expect(decrypted.equals(AES256GCM_SAMPLE.decrypted)).toEqual(true);
});
});
});
1 change: 1 addition & 0 deletions packages/voice/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"@discordjs/opus": "^0.9.0",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@stablelib/xchacha20poly1305": "^2.0.0",
"@types/node": "18.19.45",
"@vitest/coverage-v8": "2.0.5",
"cross-env": "^7.0.3",
Expand Down
71 changes: 47 additions & 24 deletions packages/voice/src/networking/Networking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/* eslint-disable id-length */
/* eslint-disable @typescript-eslint/unbound-method */
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 { CloseEvent } from 'ws';
Expand All @@ -15,7 +16,7 @@ const CHANNELS = 2;
const TIMESTAMP_INC = (48_000 / 100) * CHANNELS;
const MAX_NONCE_SIZE = 2 ** 32 - 1;

export const SUPPORTED_ENCRYPTION_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
export const SUPPORTED_ENCRYPTION_MODES = ['aead_aes256_gcm_rtpsize', 'aead_xchacha20_poly1305_rtpsize'];

/**
* The different statuses that a networking instance can hold. The order
Expand Down Expand Up @@ -187,6 +188,7 @@ function stringifyState(state: NetworkingState) {
function chooseEncryptionMode(options: string[]): string {
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.
throw new Error(`No compatible encryption modes. Available include: ${options.join(', ')}`);
}

Expand Down Expand Up @@ -442,7 +444,7 @@ export class Networking extends EventEmitter {
sequence: randomNBit(16),
timestamp: randomNBit(32),
nonce: 0,
nonceBuffer: Buffer.alloc(24),
nonceBuffer: encryptionMode === 'aead_aes256_gcm_rtpsize' ? Buffer.alloc(12) : Buffer.alloc(24),
speaking: false,
packetsPlayed: 0,
},
Expand Down Expand Up @@ -554,18 +556,18 @@ export class Networking extends EventEmitter {
* @param connectionData - The current connection data of the instance
*/
private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) {
const packetBuffer = Buffer.alloc(12);
packetBuffer[0] = 0x80;
packetBuffer[1] = 0x78;
const rtpHeader = Buffer.alloc(12);
rtpHeader[0] = 0x80;
rtpHeader[1] = 0x78;

const { sequence, timestamp, ssrc } = connectionData;

packetBuffer.writeUIntBE(sequence, 2, 2);
packetBuffer.writeUIntBE(timestamp, 4, 4);
packetBuffer.writeUIntBE(ssrc, 8, 4);
rtpHeader.writeUIntBE(sequence, 2, 2);
rtpHeader.writeUIntBE(timestamp, 4, 4);
rtpHeader.writeUIntBE(ssrc, 8, 4);

packetBuffer.copy(nonce, 0, 0, 12);
return Buffer.concat([packetBuffer, ...this.encryptOpusPacket(opusPacket, connectionData)]);
rtpHeader.copy(nonce, 0, 0, 12);
return Buffer.concat([rtpHeader, ...this.encryptOpusPacket(opusPacket, connectionData, rtpHeader)]);
}

/**
Expand All @@ -574,22 +576,43 @@ export class Networking extends EventEmitter {
* @param opusPacket - The Opus packet to encrypt
* @param connectionData - The current connection data of the instance
*/
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData) {
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData, additionalData: Buffer) {
const { secretKey, encryptionMode } = connectionData;

if (encryptionMode === 'xsalsa20_poly1305_lite') {
connectionData.nonce++;
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0);
return [
secretbox.methods.close(opusPacket, connectionData.nonceBuffer, secretKey),
connectionData.nonceBuffer.slice(0, 4),
];
} else if (encryptionMode === 'xsalsa20_poly1305_suffix') {
const random = secretbox.methods.random(24, connectionData.nonceBuffer);
return [secretbox.methods.close(opusPacket, random, secretKey), random];
}
// Both supported encryption methods want the nonce to be an incremental integer
connectionData.nonce++;
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0);

// 4 extra bytes of padding on the end of the encrypted packet
const noncePadding = connectionData.nonceBuffer.subarray(0, 4);

let encrypted;
switch (encryptionMode) {
case 'aead_aes256_gcm_rtpsize': {
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, connectionData.nonceBuffer);
cipher.setAAD(additionalData);

encrypted = Buffer.concat([cipher.update(opusPacket), cipher.final(), cipher.getAuthTag()]);

return [secretbox.methods.close(opusPacket, nonce, secretKey)];
return [encrypted, noncePadding];
}

case 'aead_xchacha20_poly1305_rtpsize': {
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
opusPacket,
additionalData,
connectionData.nonceBuffer,
secretKey,
);

return [encrypted, noncePadding];
}

default: {
// 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.
throw new RangeError(`Unsupported encryption method: ${encryptionMode}`);
}
}
}
}
Loading