use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::Aes256Gcm;
use chacha20poly1305::XChaCha20Poly1305;
use crypto_secretbox::XSalsa20Poly1305;
use crate::error::Error;
use super::rtp::{RtpPacket, parse_rtp_packet, strip_header_extension};
pub const MODE_AES256_GCM: &str = "aead_aes256_gcm_rtpsize";
pub const MODE_XCHACHA20_POLY1305: &str = "aead_xchacha20_poly1305_rtpsize";
pub const MODE_XSALSA20_POLY1305_LITE: &str = "xsalsa20_poly1305_lite_rtpsize";
pub fn supported_encryption_modes() -> &'static [&'static str] {
&[
MODE_AES256_GCM,
MODE_XCHACHA20_POLY1305,
MODE_XSALSA20_POLY1305_LITE,
]
}
pub fn select_encryption_mode(offered: &[String]) -> Option<&'static str> {
supported_encryption_modes()
.iter()
.find(|mode| offered.iter().any(|offer| offer == *mode))
.copied()
}
fn nonce_bytes<const N: usize>(nonce_counter: u32) -> [u8; N] {
let mut nonce = [0u8; N];
nonce[..4].copy_from_slice(&nonce_counter.to_be_bytes());
nonce
}
fn decryption_error(mode: &str) -> Error {
Error::Voice(format!("{mode} authentication failed"))
}
pub trait VoiceBox: Send + Sync {
fn mode(&self) -> &'static str;
fn seal(&self, header: &[u8], plaintext: &[u8], nonce_counter: u32) -> Vec<u8>;
fn open(&self, header: &[u8], ciphertext: &[u8], nonce4: [u8; 4]) -> Result<Vec<u8>, Error>;
fn open_packet(&self, data: &[u8]) -> Result<(RtpPacket, Vec<u8>), Error> {
let packet = parse_rtp_packet(data)?;
if packet.payload.len() < 4 {
return Err(Error::Voice(
"encrypted RTP payload too short for nonce suffix".to_string(),
));
}
let (ciphertext, nonce4) = packet.payload.split_at(packet.payload.len() - 4);
let nonce4: [u8; 4] = nonce4.try_into().expect("split_at guarantees 4 bytes");
let plaintext = self.open(&packet.header, ciphertext, nonce4)?;
let media = strip_header_extension(&packet, &plaintext).to_vec();
Ok((packet, media))
}
}
pub struct AeadAes256GcmRtpsize {
aead: Aes256Gcm,
}
impl VoiceBox for AeadAes256GcmRtpsize {
fn mode(&self) -> &'static str {
MODE_AES256_GCM
}
fn seal(&self, header: &[u8], plaintext: &[u8], nonce_counter: u32) -> Vec<u8> {
let nonce = nonce_bytes::<12>(nonce_counter);
let mut sealed = self
.aead
.encrypt(
(&nonce).into(),
Payload {
msg: plaintext,
aad: header,
},
)
.expect("AES-GCM sealing is infallible for in-memory buffers");
sealed.extend_from_slice(&nonce[..4]);
sealed
}
fn open(&self, header: &[u8], ciphertext: &[u8], nonce4: [u8; 4]) -> Result<Vec<u8>, Error> {
let nonce = nonce_bytes::<12>(u32::from_be_bytes(nonce4));
self.aead
.decrypt(
(&nonce).into(),
Payload {
msg: ciphertext,
aad: header,
},
)
.map_err(|_| decryption_error("AES256-GCM"))
}
}
pub struct AeadXChaCha20Poly1305Rtpsize {
aead: XChaCha20Poly1305,
}
impl VoiceBox for AeadXChaCha20Poly1305Rtpsize {
fn mode(&self) -> &'static str {
MODE_XCHACHA20_POLY1305
}
fn seal(&self, header: &[u8], plaintext: &[u8], nonce_counter: u32) -> Vec<u8> {
let nonce = nonce_bytes::<24>(nonce_counter);
let mut sealed = self
.aead
.encrypt(
(&nonce).into(),
Payload {
msg: plaintext,
aad: header,
},
)
.expect("XChaCha20 sealing is infallible for in-memory buffers");
sealed.extend_from_slice(&nonce[..4]);
sealed
}
fn open(&self, header: &[u8], ciphertext: &[u8], nonce4: [u8; 4]) -> Result<Vec<u8>, Error> {
let nonce = nonce_bytes::<24>(u32::from_be_bytes(nonce4));
self.aead
.decrypt(
(&nonce).into(),
Payload {
msg: ciphertext,
aad: header,
},
)
.map_err(|_| decryption_error("XChaCha20-Poly1305"))
}
}
pub struct XSalsa20Poly1305LiteRtpsize {
secretbox: XSalsa20Poly1305,
}
impl VoiceBox for XSalsa20Poly1305LiteRtpsize {
fn mode(&self) -> &'static str {
MODE_XSALSA20_POLY1305_LITE
}
fn seal(&self, _header: &[u8], plaintext: &[u8], nonce_counter: u32) -> Vec<u8> {
let nonce = nonce_bytes::<24>(nonce_counter);
let mut sealed = self
.secretbox
.encrypt((&nonce).into(), plaintext)
.expect("secretbox sealing is infallible for in-memory buffers");
sealed.extend_from_slice(&nonce[..4]);
sealed
}
fn open(&self, _header: &[u8], ciphertext: &[u8], nonce4: [u8; 4]) -> Result<Vec<u8>, Error> {
let nonce = nonce_bytes::<24>(u32::from_be_bytes(nonce4));
self.secretbox
.decrypt((&nonce).into(), ciphertext)
.map_err(|_| decryption_error("XSalsa20-Poly1305"))
}
}
pub fn create_voice_box(mode: &str, secret_key: &[u8]) -> Result<Box<dyn VoiceBox>, Error> {
if secret_key.len() != 32 {
return Err(Error::Voice(format!(
"voice secret key must be 32 bytes, got {}",
secret_key.len()
)));
}
match mode {
MODE_AES256_GCM => Ok(Box::new(AeadAes256GcmRtpsize {
aead: Aes256Gcm::new(secret_key.into()),
})),
MODE_XCHACHA20_POLY1305 => Ok(Box::new(AeadXChaCha20Poly1305Rtpsize {
aead: XChaCha20Poly1305::new(secret_key.into()),
})),
MODE_XSALSA20_POLY1305_LITE => Ok(Box::new(XSalsa20Poly1305LiteRtpsize {
secretbox: XSalsa20Poly1305::new(secret_key.into()),
})),
other => Err(Error::Voice(format!(
"unsupported voice encryption mode: {other}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::voice::rtp::make_test_rtp_header;
fn key() -> Vec<u8> {
(0u8..32).collect()
}
#[test]
fn voice_box_seal_open_roundtrip_all_modes() {
for mode in supported_encryption_modes() {
let voice_box = create_voice_box(mode, &key()).unwrap();
let header = make_test_rtp_header(1, 960, 7, false);
let sealed = voice_box.seal(&header, b"opus data", 1234);
let mut datagram = header.clone();
datagram.extend_from_slice(&sealed);
let (packet, payload) = voice_box.open_packet(&datagram).unwrap();
assert_eq!(payload, b"opus data", "mode {mode}");
assert_eq!(packet.ssrc, 7, "mode {mode}");
}
}
#[test]
fn voice_box_rejects_tampered_header() {
for mode in [MODE_AES256_GCM, MODE_XCHACHA20_POLY1305] {
let voice_box = create_voice_box(mode, &key()).unwrap();
let header = make_test_rtp_header(1, 960, 7, false);
let sealed = voice_box.seal(&header, b"opus data", 1);
let mut tampered = header.clone();
tampered.extend_from_slice(&sealed);
tampered[8] ^= 0xFF; assert!(voice_box.open_packet(&tampered).is_err(), "mode {mode}");
}
}
#[test]
fn voice_box_rejects_tampered_ciphertext_all_modes() {
for mode in supported_encryption_modes() {
let voice_box = create_voice_box(mode, &key()).unwrap();
let header = make_test_rtp_header(1, 960, 7, false);
let sealed = voice_box.seal(&header, b"opus data", 1);
let mut datagram = header.clone();
datagram.extend_from_slice(&sealed);
let flip_at = header.len() + 2; datagram[flip_at] ^= 0xFF;
assert!(voice_box.open_packet(&datagram).is_err(), "mode {mode}");
}
}
#[test]
fn voice_box_strips_encrypted_extension_words() {
let voice_box = create_voice_box(MODE_AES256_GCM, &key()).unwrap();
let header = make_test_rtp_header(1, 960, 7, true);
let mut plaintext = b"EXT!".to_vec();
plaintext.extend_from_slice(b"opus data");
let sealed = voice_box.seal(&header, &plaintext, 7);
let mut datagram = header.clone();
datagram.extend_from_slice(&sealed);
let (_, payload) = voice_box.open_packet(&datagram).unwrap();
assert_eq!(payload, b"opus data");
}
#[test]
fn create_voice_box_unknown_mode() {
let error = match create_voice_box("xsalsa20_poly1305", &key()) {
Err(error) => error,
Ok(_) => panic!("expected unsupported-mode error"),
};
assert!(error.to_string().contains("unsupported voice encryption mode"));
}
#[test]
fn create_voice_box_rejects_bad_key_length() {
assert!(create_voice_box(MODE_AES256_GCM, &[0u8; 16]).is_err());
}
#[test]
fn supported_modes_cover_discord_required_set() {
let modes = supported_encryption_modes();
assert!(modes.contains(&MODE_AES256_GCM));
assert!(modes.contains(&MODE_XCHACHA20_POLY1305));
}
#[test]
fn select_encryption_mode_prefers_gcm() {
let offered = vec![
"xsalsa20_poly1305_lite_rtpsize".to_string(),
"aead_xchacha20_poly1305_rtpsize".to_string(),
"aead_aes256_gcm_rtpsize".to_string(),
];
assert_eq!(select_encryption_mode(&offered), Some(MODE_AES256_GCM));
assert_eq!(select_encryption_mode(&["nope".to_string()]), None);
}
#[test]
fn open_packet_requires_nonce_suffix() {
let voice_box = create_voice_box(MODE_AES256_GCM, &key()).unwrap();
let mut datagram = make_test_rtp_header(1, 960, 7, false);
datagram.extend_from_slice(&[0, 1, 2]); assert!(voice_box.open_packet(&datagram).is_err());
}
#[test]
fn wire_compatible_with_python_sdk_known_answers() {
let header = make_test_rtp_header(0, 0, 0x0001_5040, false);
let plaintext = [0xF8u8, 0xFF, 0xFE];
for (mode, expected_hex) in [
(
MODE_AES256_GCM,
"f6434bc5c6d4266eb9863f39423afa7ecc8a8100000000",
),
(
MODE_XCHACHA20_POLY1305,
"6df972891083289edf6b1d48111a78a7acf7be00000000",
),
(
MODE_XSALSA20_POLY1305_LITE,
"2e07c17fbdb7ca8975c0f53642267b7ab2f10e00000000",
),
] {
let voice_box = create_voice_box(mode, &key()).unwrap();
let sealed = voice_box.seal(&header, &plaintext, 0);
let sealed_hex: String = sealed.iter().map(|byte| format!("{byte:02x}")).collect();
assert_eq!(sealed_hex, expected_hex, "mode {mode}");
}
}
#[test]
fn nonce_is_counter_padded_with_zeros() {
let voice_box = create_voice_box(MODE_XCHACHA20_POLY1305, &key()).unwrap();
let header = make_test_rtp_header(9, 8, 7, false);
let a = voice_box.seal(&header, b"x", 0xA1B2C3D4);
let b = voice_box.seal(&header, b"x", 0xA1B2C3D4);
assert_eq!(a, b);
assert_eq!(&a[a.len() - 4..], &[0xA1, 0xB2, 0xC3, 0xD4]);
}
}