feat: Update voice encryption modes

https://discord.com/developers/docs/change-log#voice-encryption-modes
#10451 djs
This commit is contained in:
Elysia
2024-08-23 18:04:47 +07:00
parent 5915cee4ab
commit 2cf9b36ce7
4 changed files with 125 additions and 50 deletions

View File

@@ -22,7 +22,7 @@ class SingleSilence extends Silence {
} }
} }
const SUPPORTED_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305']; const SUPPORTED_MODES = ['aead_aes256_gcm_rtpsize', 'aead_xchacha20_poly1305_rtpsize'];
const SUPPORTED_CODECS = ['VP8', 'H264']; const SUPPORTED_CODECS = ['VP8', 'H264'];
/** /**

View File

@@ -1,8 +1,9 @@
'use strict'; 'use strict';
const { Buffer } = require('node:buffer'); const { Buffer } = require('node:buffer');
const crypto = require('node:crypto');
const { Writable } = require('node:stream');
const { setTimeout } = require('node:timers'); const { setTimeout } = require('node:timers');
const { Writable } = require('stream');
const find = require('find-process'); const find = require('find-process');
const kill = require('tree-kill'); const kill = require('tree-kill');
const secretbox = require('../util/Secretbox'); const secretbox = require('../util/Secretbox');
@@ -35,7 +36,10 @@ class BaseDispatcher extends Writable {
this.extensionEnabled = extensionEnabled; this.extensionEnabled = extensionEnabled;
this._nonce = 0; this._nonce = 0;
this._nonceBuffer = Buffer.alloc(24); this._nonceBuffer =
this.player.voiceConnection.authentication.mode === 'aead_aes256_gcm_rtpsize'
? Buffer.alloc(12)
: Buffer.alloc(24);
/** /**
* The time that the stream was paused at (null if not paused) * The time that the stream was paused at (null if not paused)
@@ -298,40 +302,63 @@ class BaseDispatcher extends Writable {
return Buffer.concat([profile, ...extensionsData]); return Buffer.concat([profile, ...extensionsData]);
} }
_encrypt(buffer) { _encrypt(buffer, additionalData) {
const { secret_key, mode } = this.player.voiceConnection.authentication; const { secret_key, mode } = this.player.voiceConnection.authentication;
if (mode === 'xsalsa20_poly1305_lite') { // Both supported encryption methods want the nonce to be an incremental integer
this._nonce++; this._nonce++;
if (this._nonce > MAX_NONCE_SIZE) this._nonce = 0; if (this._nonce > MAX_NONCE_SIZE) this._nonce = 0;
this._nonceBuffer.writeUInt32BE(this._nonce, 0); this._nonceBuffer.writeUInt32BE(this._nonce, 0);
return [secretbox.methods.close(buffer, this._nonceBuffer, secret_key), this._nonceBuffer.slice(0, 4)];
} else if (mode === 'xsalsa20_poly1305_suffix') { // 4 extra bytes of padding on the end of the encrypted packet
const random = secretbox.methods.random(24); const noncePadding = this._nonceBuffer.slice(0, 4);
return [secretbox.methods.close(buffer, random, secret_key), random];
} else { let encrypted;
return [secretbox.methods.close(buffer, nonce, secret_key)];
switch (mode) {
case 'aead_aes256_gcm_rtpsize': {
const cipher = crypto.createCipheriv('aes-256-gcm', secret_key, this._nonceBuffer);
cipher.setAAD(additionalData);
encrypted = Buffer.concat([cipher.update(buffer), cipher.final(), cipher.getAuthTag()]);
return [encrypted, noncePadding];
}
case 'aead_xchacha20_poly1305_rtpsize': {
encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(
buffer,
additionalData,
this._nonceBuffer,
secret_key,
);
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: ${mode}`);
}
} }
} }
_createPacket(buffer, isLastPacket = false) { _createPacket(buffer, isLastPacket = false) {
// Header // Header
const packetBuffer = Buffer.alloc(12); const rtpHeader = Buffer.alloc(12);
packetBuffer[0] = this.extensionEnabled ? 0x90 : 0x80; // 0b10000000 | ((this.extensionEnabled ? 1 : 0) << 4); rtpHeader[0] = this.extensionEnabled ? 0x90 : 0x80; // 0b10000000 | ((this.extensionEnabled ? 1 : 0) << 4);
packetBuffer[1] = this.payloadType; rtpHeader[1] = this.payloadType;
if (this.extensionEnabled) { if (this.extensionEnabled) {
if (isLastPacket) { if (isLastPacket) {
packetBuffer[1] |= 0x80; rtpHeader[1] |= 0x80;
} }
} }
packetBuffer.writeUIntBE(this.getNewSequence(), 2, 2); rtpHeader.writeUIntBE(this.getNewSequence(), 2, 2);
packetBuffer.writeUIntBE(this.timestamp, 4, 4); rtpHeader.writeUIntBE(this.timestamp, 4, 4);
packetBuffer.writeUIntBE(this.player.voiceConnection.authentication.ssrc + this.extensionEnabled, 8, 4); rtpHeader.writeUIntBE(this.player.voiceConnection.authentication.ssrc + this.extensionEnabled, 8, 4);
packetBuffer.copy(nonce, 0, 0, 12); rtpHeader.copy(nonce, 0, 0, 12);
return Buffer.concat([packetBuffer, ...this._encrypt(buffer)]); return Buffer.concat([rtpHeader, ...this._encrypt(buffer, rtpHeader)]);
} }
_sendPacket(packet) { _sendPacket(packet) {

View File

@@ -2,6 +2,7 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const { Buffer } = require('node:buffer'); const { Buffer } = require('node:buffer');
const crypto = require('node:crypto');
const { setTimeout } = require('node:timers'); const { setTimeout } = require('node:timers');
const { IvfJoinner } = require('./video/IvfJoinner'); const { IvfJoinner } = require('./video/IvfJoinner');
const Speaking = require('../../../util/Speaking'); const Speaking = require('../../../util/Speaking');
@@ -12,6 +13,10 @@ const { SILENCE_FRAME } = require('../util/Silence');
// https://github.com/discordjs/discord.js/issues/3524#issuecomment-540373200 // https://github.com/discordjs/discord.js/issues/3524#issuecomment-540373200
const DISCORD_SPEAKING_DELAY = 250; const DISCORD_SPEAKING_DELAY = 250;
const HEADER_EXTENSION_BYTE = Buffer.from([0xbe, 0xde]);
const UNPADDED_NONCE_LENGTH = 4;
const AUTH_TAG_LENGTH = 16;
class Readable extends require('stream').Readable { class Readable extends require('stream').Readable {
_read() {} // eslint-disable-line no-empty-function _read() {} // eslint-disable-line no-empty-function
} }
@@ -19,13 +24,18 @@ class Readable extends require('stream').Readable {
class PacketHandler extends EventEmitter { class PacketHandler extends EventEmitter {
constructor(receiver) { constructor(receiver) {
super(); super();
this.nonce = Buffer.alloc(24);
this.receiver = receiver; this.receiver = receiver;
this.nonce = null;
this.streams = new Map(); this.streams = new Map();
this.videoStreams = new Map(); this.videoStreams = new Map();
this.speakingTimeouts = new Map(); this.speakingTimeouts = new Map();
} }
resetNonce() {
this.nonce =
this.receiver.connection.authentication.mode === 'aead_aes256_gcm_rtpsize' ? Buffer.alloc(12) : Buffer.alloc(24);
}
get connection() { get connection() {
return this.receiver.connection; return this.receiver.connection;
} }
@@ -56,29 +66,58 @@ class PacketHandler extends EventEmitter {
parseBuffer(buffer) { parseBuffer(buffer) {
const { secret_key, mode } = this.receiver.connection.authentication; const { secret_key, mode } = this.receiver.connection.authentication;
if (!this.nonce) {
// Choose correct nonce depending on encryption this.resetNonce();
let end;
if (mode === 'xsalsa20_poly1305_lite') {
buffer.copy(this.nonce, 0, buffer.length - 4);
end = buffer.length - 4;
} else if (mode === 'xsalsa20_poly1305_suffix') {
buffer.copy(this.nonce, 0, buffer.length - 24);
end = buffer.length - 24;
} else {
buffer.copy(this.nonce, 0, 0, 12);
} }
// Open packet // Copy the last 4 bytes of unpadded nonce to the padding of (12 - 4) or (24 - 4) bytes
if (!secret_key) return new Error('secret_key cannot be null or undefined'); buffer.copy(this.nonce, 0, buffer.length - UNPADDED_NONCE_LENGTH);
let packet = secretbox.methods.open(buffer.slice(12, end), this.nonce, secret_key);
if (!packet) return new Error('Failed to decrypt voice packet');
packet = Buffer.from(packet);
// Strip RTP Header Extensions (one-byte only) let headerSize = 12;
if (packet[0] === 0xbe && packet[1] === 0xde) { const first = buffer.readUint8();
const headerExtensionLength = packet.readUInt16BE(2); if ((first >> 4) & 0x01) headerSize += 4;
packet = packet.subarray(4 + 4 * headerExtensionLength);
// The unencrypted RTP header contains 12 bytes, HEADER_EXTENSION and the extension size
const header = buffer.slice(0, headerSize);
// Encrypted contains the extension, if any, the opus packet, and the auth tag
const encrypted = buffer.slice(headerSize, buffer.length - AUTH_TAG_LENGTH - UNPADDED_NONCE_LENGTH);
const authTag = buffer.slice(
buffer.length - AUTH_TAG_LENGTH - UNPADDED_NONCE_LENGTH,
buffer.length - UNPADDED_NONCE_LENGTH,
);
let packet;
switch (mode) {
case 'aead_aes256_gcm_rtpsize': {
const decipheriv = crypto.createDecipheriv('aes-256-gcm', secret_key, this.nonce);
decipheriv.setAAD(header);
decipheriv.setAuthTag(authTag);
packet = Buffer.concat([decipheriv.update(encrypted), decipheriv.final()]);
break;
}
case 'aead_xchacha20_poly1305_rtpsize': {
// Combined mode expects authtag in the encrypted message
packet = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_decrypt(
Buffer.concat([encrypted, authTag]),
header,
this.nonce,
secret_key,
);
packet = Buffer.from(packet);
break;
}
default: {
throw new RangeError(`Unsupported decryption method: ${mode}`);
}
}
// Strip decrypted RTP Header Extension if present
if (buffer.slice(12, 14).compare(HEADER_EXTENSION_BYTE) === 0) {
const headerExtensionLength = buffer.slice(14).readUInt16BE();
packet = packet.subarray(4 * headerExtensionLength);
} }
// Ex VP8 // Ex VP8

View File

@@ -2,25 +2,32 @@
const libs = { const libs = {
sodium: sodium => ({ sodium: sodium => ({
/** @deprecated */
open: sodium.api.crypto_secretbox_open_easy, open: sodium.api.crypto_secretbox_open_easy,
/** @deprecated */
close: sodium.api.crypto_secretbox_easy, close: sodium.api.crypto_secretbox_easy,
random: n => sodium.randombytes_buf(n), random: n => sodium.randombytes_buf(n),
crypto_aead_xchacha20poly1305_ietf_encrypt: (message, additionalData, nonce, key) =>
sodium.api.crypto_aead_xchacha20poly1305_ietf_encrypt(message, additionalData, null, nonce, key),
crypto_aead_xchacha20poly1305_ietf_decrypt: (message, additionalData, nonce, key) =>
sodium.api.crypto_aead_xchacha20poly1305_ietf_decrypt(message, additionalData, null, nonce, key),
}), }),
'libsodium-wrappers': sodium => ({ 'libsodium-wrappers': sodium => ({
/** @deprecated */
open: sodium.crypto_secretbox_open_easy, open: sodium.crypto_secretbox_open_easy,
/** @deprecated */
close: sodium.crypto_secretbox_easy, close: sodium.crypto_secretbox_easy,
random: n => sodium.randombytes_buf(n), random: n => sodium.randombytes_buf(n),
}), crypto_aead_xchacha20poly1305_ietf_encrypt: (message, additionalData, nonce, key) =>
tweetnacl: tweetnacl => ({ sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(message, additionalData, null, nonce, key),
open: tweetnacl.secretbox.open, crypto_aead_xchacha20poly1305_ietf_decrypt: (message, additionalData, nonce, key) =>
close: tweetnacl.secretbox, sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message, additionalData, nonce, key),
random: n => tweetnacl.randomBytes(n),
}), }),
}; };
function NoLib() { function NoLib() {
throw new Error( throw new Error(
'Cannot play audio as no valid encryption package is installed.\n- Install sodium, libsodium-wrappers, or tweetnacl.', 'Cannot play audio as no valid encryption package is installed.\n- Install sodium or libsodium-wrappers.',
); );
} }
@@ -28,6 +35,8 @@ exports.methods = {
open: NoLib, open: NoLib,
close: NoLib, close: NoLib,
random: NoLib, random: NoLib,
crypto_aead_xchacha20poly1305_ietf_encrypt: NoLib,
crypto_aead_xchacha20poly1305_ietf_decrypt: NoLib,
}; };
(async () => { (async () => {