mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-13 10:03:31 +01:00
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 <33201955+Jiralite@users.noreply.github.com> Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
This commit is contained in:
@@ -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';
|
||||
@@ -15,7 +16,12 @@ 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_xchacha20_poly1305_rtpsize'];
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* The different statuses that a networking instance can hold. The order
|
||||
@@ -187,6 +193,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(', ')}`);
|
||||
}
|
||||
|
||||
@@ -442,7 +449,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,
|
||||
},
|
||||
@@ -554,18 +561,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)]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,22 +581,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);
|
||||
|
||||
return [secretbox.methods.close(opusPacket, nonce, secretKey)];
|
||||
// 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 [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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable jsdoc/check-param-names */
|
||||
|
||||
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';
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
import { SSRCMap } from './SSRCMap';
|
||||
import { SpeakingMap } from './SpeakingMap';
|
||||
|
||||
const HEADER_EXTENSION_BYTE = Buffer.from([0xbe, 0xde]);
|
||||
const UNPADDED_NONCE_LENGTH = 4;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Attaches to a VoiceConnection, allowing you to receive audio packets from other
|
||||
* users that are speaking.
|
||||
@@ -87,22 +92,48 @@ export class VoiceReceiver {
|
||||
}
|
||||
|
||||
private decrypt(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array) {
|
||||
// Choose correct nonce depending on encryption
|
||||
let end;
|
||||
if (mode === 'xsalsa20_poly1305_lite') {
|
||||
buffer.copy(nonce, 0, buffer.length - 4);
|
||||
end = buffer.length - 4;
|
||||
} else if (mode === 'xsalsa20_poly1305_suffix') {
|
||||
buffer.copy(nonce, 0, buffer.length - 24);
|
||||
end = buffer.length - 24;
|
||||
} else {
|
||||
buffer.copy(nonce, 0, 0, 12);
|
||||
}
|
||||
// Copy the last 4 bytes of unpadded nonce to the padding of (12 - 4) or (24 - 4) bytes
|
||||
buffer.copy(nonce, 0, buffer.length - UNPADDED_NONCE_LENGTH);
|
||||
|
||||
// Open packet
|
||||
const decrypted = methods.open(buffer.slice(12, end), nonce, secretKey);
|
||||
if (!decrypted) return;
|
||||
return Buffer.from(decrypted);
|
||||
let headerSize = 12;
|
||||
const first = buffer.readUint8();
|
||||
if ((first >> 4) & 0x01) headerSize += 4;
|
||||
|
||||
// The unencrypted RTP header contains 12 bytes, HEADER_EXTENSION and the extension size
|
||||
const header = buffer.subarray(0, headerSize);
|
||||
|
||||
// Encrypted contains the extension, if any, the opus packet, and the auth tag
|
||||
const encrypted = buffer.subarray(headerSize, buffer.length - AUTH_TAG_LENGTH - UNPADDED_NONCE_LENGTH);
|
||||
const authTag = buffer.subarray(
|
||||
buffer.length - AUTH_TAG_LENGTH - UNPADDED_NONCE_LENGTH,
|
||||
buffer.length - UNPADDED_NONCE_LENGTH,
|
||||
);
|
||||
|
||||
switch (mode) {
|
||||
case 'aead_aes256_gcm_rtpsize': {
|
||||
const decipheriv = crypto.createDecipheriv('aes-256-gcm', secretKey, nonce);
|
||||
decipheriv.setAAD(header);
|
||||
decipheriv.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([decipheriv.update(encrypted), decipheriv.final()]);
|
||||
}
|
||||
|
||||
case 'aead_xchacha20_poly1305_rtpsize': {
|
||||
// Combined mode expects authtag in the encrypted message
|
||||
return Buffer.from(
|
||||
methods.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
Buffer.concat([encrypted, authTag]),
|
||||
header,
|
||||
nonce,
|
||||
secretKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new RangeError(`Unsupported decryption method: ${mode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,10 +149,11 @@ export class VoiceReceiver {
|
||||
let packet = this.decrypt(buffer, mode, nonce, secretKey);
|
||||
if (!packet) return;
|
||||
|
||||
// Strip RTP Header Extensions (one-byte only)
|
||||
if (packet[0] === 0xbe && packet[1] === 0xde) {
|
||||
const headerExtensionLength = packet.readUInt16BE(2);
|
||||
packet = packet.subarray(4 + 4 * headerExtensionLength);
|
||||
// Strip decrypted RTP Header Extension if present
|
||||
// The header is only indicated in the original data, so compare with buffer first
|
||||
if (buffer.subarray(12, 14).compare(HEADER_EXTENSION_BYTE) === 0) {
|
||||
const headerExtensionLength = buffer.subarray(14).readUInt16BE();
|
||||
packet = packet.subarray(4 * headerExtensionLength);
|
||||
}
|
||||
|
||||
return packet;
|
||||
|
||||
@@ -1,75 +1,135 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
interface Methods {
|
||||
close(opusPacket: Buffer, nonce: Buffer, secretKey: Uint8Array): Buffer;
|
||||
open(buffer: Buffer, nonce: Buffer, secretKey: Uint8Array): Buffer | null;
|
||||
random(bytes: number, nonce: Buffer): Buffer;
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
cipherText: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
): Buffer;
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
plaintext: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
): Buffer;
|
||||
}
|
||||
|
||||
const libs = {
|
||||
'sodium-native': (sodium: any): Methods => ({
|
||||
open: (buffer: Buffer, nonce: Buffer, secretKey: Uint8Array) => {
|
||||
if (buffer) {
|
||||
const output = Buffer.allocUnsafe(buffer.length - sodium.crypto_box_MACBYTES);
|
||||
if (sodium.crypto_secretbox_open_easy(output, buffer, nonce, secretKey)) return output;
|
||||
}
|
||||
|
||||
return null;
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt: (
|
||||
cipherText: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
) => {
|
||||
const message = Buffer.alloc(cipherText.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(message, null, cipherText, additionalData, nonce, key);
|
||||
return message;
|
||||
},
|
||||
close: (opusPacket: Buffer, nonce: Buffer, secretKey: Uint8Array) => {
|
||||
const output = Buffer.allocUnsafe(opusPacket.length + sodium.crypto_box_MACBYTES);
|
||||
sodium.crypto_secretbox_easy(output, opusPacket, nonce, secretKey);
|
||||
return output;
|
||||
},
|
||||
random: (num: number, buffer: Buffer = Buffer.allocUnsafe(num)) => {
|
||||
sodium.randombytes_buf(buffer);
|
||||
return buffer;
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt: (
|
||||
plaintext: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
) => {
|
||||
const cipherText = Buffer.alloc(plaintext.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(cipherText, plaintext, additionalData, null, nonce, key);
|
||||
return cipherText;
|
||||
},
|
||||
}),
|
||||
sodium: (sodium: any): Methods => ({
|
||||
open: sodium.api.crypto_secretbox_open_easy,
|
||||
close: sodium.api.crypto_secretbox_easy,
|
||||
random: (num: number, buffer: Buffer = Buffer.allocUnsafe(num)) => {
|
||||
sodium.api.randombytes_buf(buffer);
|
||||
return buffer;
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt: (
|
||||
cipherText: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
) => {
|
||||
return sodium.api.crypto_aead_xchacha20poly1305_ietf_decrypt(cipherText, additionalData, null, nonce, key);
|
||||
},
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt: (
|
||||
plaintext: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
) => {
|
||||
return sodium.api.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, additionalData, null, nonce, key);
|
||||
},
|
||||
}),
|
||||
'libsodium-wrappers': (sodium: any): Methods => ({
|
||||
open: sodium.crypto_secretbox_open_easy,
|
||||
close: sodium.crypto_secretbox_easy,
|
||||
random: sodium.randombytes_buf,
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt: (
|
||||
cipherText: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
) => {
|
||||
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, cipherText, additionalData, nonce, key);
|
||||
},
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt: (
|
||||
plaintext: Buffer,
|
||||
additionalData: Buffer,
|
||||
nonce: Buffer,
|
||||
key: ArrayBufferLike,
|
||||
) => {
|
||||
return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, additionalData, null, nonce, key);
|
||||
},
|
||||
}),
|
||||
tweetnacl: (tweetnacl: any): Methods => ({
|
||||
open: tweetnacl.secretbox.open,
|
||||
close: tweetnacl.secretbox,
|
||||
random: tweetnacl.randomBytes,
|
||||
'@stablelib/xchacha20poly1305': (stablelib: any): Methods => ({
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt(plaintext, additionalData, nonce, key) {
|
||||
const crypto = new stablelib.XChaCha20Poly1305(key);
|
||||
return crypto.open(nonce, plaintext, additionalData);
|
||||
},
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt(cipherText, additionalData, nonce, key) {
|
||||
const crypto = new stablelib.XChaCha20Poly1305(key);
|
||||
return crypto.seal(nonce, cipherText, additionalData);
|
||||
},
|
||||
}),
|
||||
'@noble/ciphers/chacha': (noble: any): Methods => ({
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt(cipherText, additionalData, nonce, key) {
|
||||
const chacha = noble.xchacha20poly1305(key, nonce, additionalData);
|
||||
return chacha.decrypt(cipherText);
|
||||
},
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, additionalData, nonce, key) {
|
||||
const chacha = noble.xchacha20poly1305(key, nonce, additionalData);
|
||||
return chacha.encrypt(plaintext);
|
||||
},
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const fallbackError = () => {
|
||||
throw new Error(
|
||||
`Cannot play audio as no valid encryption package is installed.
|
||||
- Install sodium, libsodium-wrappers, or tweetnacl.
|
||||
- Install one of:
|
||||
- sodium
|
||||
- libsodium-wrappers
|
||||
- @stablelib/xchacha20poly1305
|
||||
- @noble/ciphers.
|
||||
- Use the generateDependencyReport() function for more information.\n`,
|
||||
);
|
||||
};
|
||||
|
||||
const methods: Methods = {
|
||||
open: fallbackError,
|
||||
close: fallbackError,
|
||||
random: fallbackError,
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt: fallbackError,
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt: fallbackError,
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
export const secretboxLoadPromise = new Promise<void>(async (resolve) => {
|
||||
for (const libName of Object.keys(libs) as (keyof typeof libs)[]) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
||||
const lib = require(libName);
|
||||
if (libName === 'libsodium-wrappers' && lib.ready) await lib.ready;
|
||||
const lib = await import(libName);
|
||||
|
||||
if (libName === 'libsodium-wrappers' && lib.ready) {
|
||||
await lib.ready;
|
||||
}
|
||||
|
||||
Object.assign(methods, libs[libName](lib));
|
||||
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
})();
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
export { methods };
|
||||
|
||||
@@ -68,7 +68,8 @@ export function generateDependencyReport() {
|
||||
addVersion('sodium-native');
|
||||
addVersion('sodium');
|
||||
addVersion('libsodium-wrappers');
|
||||
addVersion('tweetnacl');
|
||||
addVersion('@stablelib/xchacha20poly1305');
|
||||
addVersion('@noble/ciphers');
|
||||
report.push('');
|
||||
|
||||
// ffmpeg
|
||||
|
||||
Reference in New Issue
Block a user