Skip to content
Draft
124 changes: 89 additions & 35 deletions lib/voice/VoiceConnection.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ try {

/** @type {import("@snazzah/davey")?} */
let Davey = null;
/** @type {import("@stablelib/xchacha20poly1305")?} */
let StableLib = null;
/** @type {import("@projectdysnomia/libsodium")?} */
let Sodium = null;
/** @type {import("node:crypto")?} */
let crypto = null;
let aes256Available = false;

Expand Down Expand Up @@ -107,8 +110,6 @@ class VoiceConnection extends EventEmitter {
reconnecting = false;
reinitializing = false;
samplingRate = 48_000;
sendBuffer = Buffer.allocUnsafe(16 + 32 + MAX_FRAME_SIZE);
sendHeader = Buffer.alloc(12);
sequence = 0;
speaking = false;
ssrcUserMap = {};
Expand All @@ -119,8 +120,10 @@ class VoiceConnection extends EventEmitter {
#daveDowngraded = false;
#davePendingTransitions = new Map();
#nonce = 0;
/** @type {import("@projectdysnomia/libsodium").BufferPointer?} */
#nonceBuffer = null;
#send;
/** @type {import("@stablelib/xchacha20poly1305").XChaCha20Poly1305?} */
#stablelib = null;
/** @type {String?} */
#userID = null;
Expand All @@ -142,7 +145,7 @@ class VoiceConnection extends EventEmitter {

if(!Sodium && !StableLib) {
try {
Sodium = require("sodium-native");
Sodium = require("@projectdysnomia/libsodium");
} catch {
try {
StableLib = require("@stablelib/xchacha20poly1305");
Expand Down Expand Up @@ -187,6 +190,9 @@ class VoiceConnection extends EventEmitter {
this.opus = {};
}

this.sendBuffer = this.#alloc(16 + 32 + MAX_FRAME_SIZE, false);
this.sendHeader = this.#alloc(12);

this.sendHeader[0] = 0x80;
this.sendHeader[1] = 0x78;

Expand Down Expand Up @@ -461,10 +467,10 @@ class VoiceConnection extends EventEmitter {
}
case VoiceOPCodes.SESSION_DESCRIPTION: {
this.mode = packet.d.mode;
this.secret = Buffer.from(packet.d.secret_key);
this.#nonceBuffer = Buffer.alloc(this.mode === "aead_aes256_gcm_rtpsize" ? 12 : 24);
this.secret = this.#transfer(Buffer.from(packet.d.secret_key));
this.#nonceBuffer = this.#alloc(this.mode === "aead_aes256_gcm_rtpsize" ? 12 : 24);
if(this.mode === "aead_xchacha20_poly1305_rtpsize" && StableLib) {
this.#stablelib = new StableLib.XChaCha20Poly1305(this.secret);
this.#stablelib = new StableLib.XChaCha20Poly1305(this.secret.buffer);
}
this.daveProtocolVersion = packet.d.dave_protocol_version;
this.connecting = false;
Expand Down Expand Up @@ -688,6 +694,10 @@ class VoiceConnection extends EventEmitter {
this.sessionID = null;
this.token = null;
this.wsSequence = -1;
this.secret?.free();
this.#nonceBuffer?.free();
this.secret = null;
this.#nonceBuffer = null;
this.#stablelib = null;
this.updateVoiceState();
/**
Expand Down Expand Up @@ -869,8 +879,8 @@ class VoiceConnection extends EventEmitter {
const hasExtension = !!(msg[0] & 0b10000);
const hasPadding = !!(msg[0] & 0b100000);
const cc = msg[0] & 0b1111;
const nonce = Buffer.alloc(this.mode === "aead_aes256_gcm_rtpsize" ? 12 : 24);
msg.copy(nonce, 0, msg.length - 4, msg.length);
const nonce = this.#alloc(this.mode === "aead_aes256_gcm_rtpsize" ? 12 : 24);
msg.copy(nonce.buffer, 0, msg.length - 4, msg.length);

// Header Size = Fixed Header Length + (CSRC Identifier Length * CSRC count) + Extension
let headerSize = 12 + (cc * 4);
Expand All @@ -880,23 +890,30 @@ class VoiceConnection extends EventEmitter {

let data;
if(this.mode === "aead_aes256_gcm_rtpsize") {
const decipher = crypto.createDecipheriv("aes-256-gcm", this.secret, nonce);
const decipher = crypto.createDecipheriv("aes-256-gcm", this.secret.buffer, nonce.buffer);
decipher.setAAD(msg.subarray(0, headerSize));
decipher.setAuthTag(msg.subarray(msg.length - 20, msg.length - 4));
data = Buffer.concat([decipher.update(msg.subarray(headerSize, msg.length - 20)), decipher.final()]);
} else if(Sodium) {
data = Buffer.alloc(msg.length - (headerSize + 4) - Sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
const msgPtr = this.#transfer(msg);
const d = this.#alloc(msg.length - (headerSize + 4) - Sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
Sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
data,
d,
null,
msg.subarray(headerSize, msg.length - 4),
msg.subarray(0, headerSize),
msgPtr.subarray(headerSize, msg.length - 4),
msgPtr.subarray(0, headerSize),
nonce,
this.secret
);

// SAFE: the native backend has no concept of freeing memory, so the buffer can be used directly
data = Sodium.native ? d.buffer : Buffer.from(d.buffer);

msgPtr.free();
d.free();
} else if(this.#stablelib) {
data = this.#stablelib.open(
nonce,
nonce.buffer,
msg.subarray(headerSize, msg.length - 4),
msg.subarray(0, headerSize)
);
Expand All @@ -907,6 +924,8 @@ class VoiceConnection extends EventEmitter {
data = Buffer.from(data.buffer);
}

nonce.free();

// RFC3550 5.1: Padding (may need testing)
if(hasPadding) {
const paddingAmount = data[data.length - 1];
Expand Down Expand Up @@ -1137,6 +1156,8 @@ class VoiceConnection extends EventEmitter {
}

_destroy() {
this.sendBuffer.free();
this.sendHeader.free();
if(this.opus) {
for(const key in this.opus) {
this.opus[key].delete?.();
Expand All @@ -1155,28 +1176,28 @@ class VoiceConnection extends EventEmitter {
}

_sendAudioFrame(unencryptedFrame) {
const headerSize = this.sendHeader.length;
this.sendHeader.writeUInt16BE(this.sequence, 2);
this.sendHeader.writeUInt32BE(this.timestamp, 4);
const headerSize = this.sendHeader.buffer.length;
this.sendHeader.buffer.writeUInt16BE(this.sequence, 2);
this.sendHeader.buffer.writeUInt32BE(this.timestamp, 4);

this.#nonce = (this.#nonce + 1) >>> 0;
this.#nonceBuffer.writeUInt32BE(this.#nonce, 0);
this.#nonceBuffer.buffer.writeUInt32BE(this.#nonce, 0);

const frame = this.#daveReady && !unencryptedFrame.equals(SILENCE_FRAME) ? this.daveSession.encryptOpus(unencryptedFrame) : unencryptedFrame;

if(this.mode === "aead_aes256_gcm_rtpsize") {
const cipher = crypto.createCipheriv("aes-256-gcm", this.secret, this.#nonceBuffer);
cipher.setAAD(this.sendHeader);
const cipher = crypto.createCipheriv("aes-256-gcm", this.secret.buffer, this.#nonceBuffer.buffer);
cipher.setAAD(this.sendHeader.buffer);
const result = Buffer.concat([cipher.update(frame), cipher.final(), cipher.getAuthTag()]);
this.sendHeader.copy(this.sendBuffer, 0, 0, headerSize);
result.copy(this.sendBuffer, headerSize);
this.#nonceBuffer.copy(this.sendBuffer, headerSize + result.length, 0, 4); // nonce padding
return this.sendUDPPacket(this.sendBuffer.subarray(0, headerSize + result.length + 4));
this.sendHeader.buffer.copy(this.sendBuffer.buffer, 0, 0, headerSize);
result.copy(this.sendBuffer.buffer, headerSize);
this.#nonceBuffer.buffer.copy(this.sendBuffer.buffer, headerSize + result.length, 0, 4); // nonce padding
return this.sendUDPPacket(this.sendBuffer.subarray(0, headerSize + result.length + 4).buffer);
} else if(Sodium) {
const ABYTES = Sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES;
const length = frame.length + ABYTES;
this.sendBuffer.fill(0, headerSize + frame.length, headerSize + frame.length + ABYTES);
frame.copy(this.sendBuffer, headerSize);
this.sendBuffer.buffer.fill(0, headerSize + frame.length, headerSize + frame.length + ABYTES);
frame.copy(this.sendBuffer.buffer, headerSize);
Sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
this.sendBuffer.subarray(headerSize, headerSize + length),
this.sendBuffer.subarray(headerSize, headerSize + frame.length),
Expand All @@ -1185,21 +1206,33 @@ class VoiceConnection extends EventEmitter {
this.#nonceBuffer,
this.secret
);
this.sendHeader.copy(this.sendBuffer, 0, 0, headerSize);
this.#nonceBuffer.copy(this.sendBuffer, headerSize + length, 0, 4); // nonce padding
return this.sendUDPPacket(this.sendBuffer.subarray(0, headerSize + length + 4));
this.sendHeader.buffer.copy(this.sendBuffer, 0, 0, headerSize);
this.#nonceBuffer.buffer.copy(this.sendBuffer, headerSize + length, 0, 4); // nonce padding
return this.sendUDPPacket(this.sendBuffer.subarray(0, headerSize + length + 4).buffer);
} else if(this.#stablelib) {
const length = frame.length + StableLib.TAG_LENGTH;
this.sendBuffer.fill(0, headerSize + frame.length, headerSize + length);
this.#stablelib.seal(
this.#nonceBuffer,
this.#nonceBuffer.buffer,
frame,
this.sendHeader,
this.sendBuffer.subarray(headerSize, headerSize + length)
this.sendHeader.buffer,
this.sendBuffer.subarray(headerSize, headerSize + length).buffer
);
this.sendHeader.copy(this.sendBuffer, 0, 0, headerSize);
this.#nonceBuffer.copy(this.sendBuffer, headerSize + length, 0, 4); // nonce padding
return this.sendUDPPacket(this.sendBuffer.subarray(0, headerSize + length + 4));
this.sendHeader.buffer.copy(this.sendBuffer.buffer, 0, 0, headerSize);
this.#nonceBuffer.buffer.copy(this.sendBuffer.buffer, headerSize + length, 0, 4); // nonce padding
return this.sendUDPPacket(this.sendBuffer.subarray(0, headerSize + length + 4).buffer);
}
}

/**
* @param {Number} size
* @param {Boolean} zero
*/
#alloc(size, zero = true) {
if(Sodium) {
return Sodium.alloc(size, zero);
} else {
return this.#makeBufferPointer(zero ? Buffer.alloc(size) : Buffer.allocUnsafe(size));
}
}

Expand Down Expand Up @@ -1247,6 +1280,18 @@ class VoiceConnection extends EventEmitter {
return true;
}

/** @param {Buffer} buf */
#makeBufferPointer(buf) {
// Keep in sync with BufferPointer in @projectdysnomia/libsodium to maintain compatibility
return {
buffer: buf,
free: () => {},
subarray: (start, end) => {
return this.#makeBufferPointer(buf.subarray(start, end));
}
};
}

#reinitDaveSession(userID, channelID) {
if(this.daveProtocolVersion > 0) {
if(this.daveSession) {
Expand Down Expand Up @@ -1297,6 +1342,15 @@ class VoiceConnection extends EventEmitter {
this.current.timeout = setTimeout(this.#send, this.current.startTime + this.current.pausedTime + this.current.playTime - Date.now());
}

/** @param {Buffer} buf */
#transfer(buf) {
if(Sodium) {
return Sodium.transfer(buf);
} else {
return this.#makeBufferPointer(buf);
}
}

[util.inspect.custom]() {
return Base.prototype[util.inspect.custom].call(this);
}
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"optionalDependencies": {
"@snazzah/davey": "^0.1.12",
"@stablelib/xchacha20poly1305": "~1.0.1",
"@projectdysnomia/libsodium": "github:projectdysnomia/libsodium#acefd1b44526953eed4f0a87e31149963d052d21",
"opusscript": "^0.1.1"
},
"browser": {
Expand All @@ -70,6 +70,7 @@
},
"peerDependencies": {
"@discordjs/opus": "^0.10.0",
"@stablelib/xchacha20poly1305": "~1.0.1",
"erlpack": "github:discord/erlpack",
"eventemitter3": "^5.0.1",
"pako": "^2.1.0",
Expand All @@ -80,6 +81,9 @@
"@discordjs/opus": {
"optional": true
},
"@stablelib/xchacha20poly1305": {
"optional": true
},
"eventemitter3": {
"optional": true
},
Expand Down
Loading