using axXez.Threading;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace axXez.TS3.Protocol
{
/// TeamSpeak 3 UDP voice client. Handles the full handshake and packet crypto.
public class VoiceClient : LoopTaskHandler, ITeamspeakClient
{
private const int C2sHeaderLen = 5; // PId(2) + CId(2) + PT(1)
private const int S2cHeaderLen = 3; // PId(2) + PT(1)
private const int MaxPacketSize = 500;
private const int MaxOutContent = MaxPacketSize - VoiceCrypto.MacLen - C2sHeaderLen;
private const byte FlagFragmented = 0x10;
private const byte FlagNewProtocol = 0x20;
private const byte FlagUnencrypted = 0x80;
// TS3AudioBot version signature accepted by all TS3 servers
private static readonly string VersionSignPlatform = "Linux";
private static readonly string VersionSignVersion = "3.?.? [Build: 5680278000]";
private static readonly string VersionSignSign =
"Hjd+N58Gv3ENhoKmGYy2bNRBsNNgm5kpiaQWxOj5HN2DXttG6REjymSwJtpJ8muC2gSwRuZi0R+8Laan5ts5CQ==";
/// Handler raised when a transport or protocol error occurs.
public delegate void VoiceErrorHandler(VoiceClient sender, Exception ex);
/// Handler raised when a command packet is received from the server.
public delegate void VoiceCommandHandler(VoiceClient sender, TsMessage command);
/// Handler raised when a decoded voice packet is received.
public delegate void VoiceDataHandler(VoiceClient sender, byte[] data);
/// Handler raised when this client is moved to a different channel.
public delegate void VoiceChannelChangedHandler(VoiceClient sender, int newChannelId);
///
public event TeamspeakClientHandler OnConnected;
///
public event TeamspeakClientHandler OnDisconnected;
/// Raised when a transport or protocol error occurs.
public event VoiceErrorHandler OnError;
/// Raised when a command packet is received from the server.
public event VoiceCommandHandler OnCommand;
/// Raised when a decoded voice packet is received.
public event VoiceDataHandler OnVoiceData;
/// Raised when this client is moved to a different channel.
public event VoiceChannelChangedHandler OnChannelChanged;
///
public event TeamspeakClientHandler OnTextMessage;
/// Current connection state of the voice client.
public VoiceClientState State { get; private set; } = VoiceClientState.Disconnected;
/// Client ID assigned by the server after a successful handshake.
public int ClientId { get; private set; }
/// Channel ID this client is currently in. Updated on every channel move.
public int CurrentChannelId { get; private set; }
///
public string Host => _host;
///
public int Port => _port;
/// Display nickname this client uses on the server.
public string Nickname => _nickname;
///
public bool IsConnected => State == VoiceClientState.Connected;
/// Milliseconds to wait before each reconnect attempt. Default is 5000.
public int ReconnectDelay { get; set; } = 5000;
private readonly LoopTask resendProcessor;
private readonly LoopTask commandProcessor;
private readonly ConcurrentQueue _commandQueue = new();
private readonly string _host;
private readonly int _port;
private readonly string _nickname;
private readonly VoiceIdentity _identity;
private readonly VoiceClientOptions _opts;
private UdpClient _udp;
// Per-type packet counters for outgoing (C2S) and incoming (S2C) directions
private readonly ushort[] _outCounters = new ushort[9];
private readonly uint[] _outGenerations = new uint[9];
private readonly ushort[] _inCounters = new ushort[9];
private readonly uint[] _inGenerations = new uint[9];
// Resend tracking
private readonly Dictionary _resendMap = new();
private (byte[] raw, long firstSend, long lastSend)? _initResend;
private long _lastMessageTick;
private long _lastPingTick;
// Crypto state
private bool _cryptoInitComplete;
private byte[] _ivStruct;
private readonly byte[] _fakeSignature = new byte[VoiceCrypto.MacLen];
private byte[] _alphaTmp;
// Fragment reassembly
private readonly List _fragmentBuffer = new();
private bool _fragmenting;
private byte _fragmentFlags;
// Channel map (name → cid) for auto-move after channellistfinished
private readonly Dictionary _channelMap = new();
private IPEndPoint _remoteEndpoint;
private readonly object _lock = new();
/// Initializes a new targeting the given host and port. Call to connect.
public VoiceClient(string host, int port = 9987, string nickname = "Bot", VoiceIdentity identity = null, VoiceClientOptions options = null)
{
_host = host;
_port = port;
_nickname = nickname;
_identity = identity ?? VoiceIdentity.Generate();
_opts = options ?? new VoiceClientOptions();
AutoRestart = true;
resendProcessor = new(resendLoop, null, resendLoopStop, autoRestart: false);
commandProcessor = new(commandLoop, null, commandLoopStop, autoRestart: true);
}
protected override async Task loopStart(CancellationToken ct, bool isAutoRestart)
{
if (isAutoRestart)
{
GlobalLogger.Log("VoiceClient", $"Reconnecting in {ReconnectDelay / 1000}s...");
await Task.Delay(ReconnectDelay, ct);
}
GlobalLogger.Log("VoiceClient", $"Connecting to {_host}:{_port}...");
ResetState();
// Resolve host once so every Send() uses a pre-resolved IPEndPoint.
IPAddress addr = IPAddress.TryParse(_host, out var parsed)
? parsed
: (await Dns.GetHostAddressesAsync(_host, AddressFamily.InterNetwork, ct))[0];
_remoteEndpoint = new IPEndPoint(addr, _port);
_udp = new UdpClient(0); // 0 = OS assigns random local port
_udp.Client.SendBufferSize = 1024 * 1024;
_udp.Client.ReceiveBufferSize = 1024 * 1024;
_lastMessageTick = Environment.TickCount64;
// Increment command counter without sending (matches TS3 client behaviour)
IncrementOutCounter(VoicePacketType.Command);
resendProcessor.Start();
commandProcessor.Start();
SendInitPacket(BuildInit0());
}
protected override async Task loop(CancellationToken ct)
{
UdpReceiveResult result = await _udp.ReceiveAsync(ct);
lock (_lock)
_lastMessageTick = Environment.TickCount64;
try
{
HandleIncomingPacket(result.Buffer);
}
catch (Exception ex)
{
GlobalLogger.LogError("VoiceClient", "Packet handling failed", ex);
OnError?.Invoke(this, ex);
}
}
protected override void loopStop(Exception exception)
{
commandProcessor.Stop();
resendProcessor.Stop();
try { _udp?.Close(); } catch { }
_udp = null;
bool wasConnected = State == VoiceClientState.Connected || State == VoiceClientState.Disconnecting;
State = VoiceClientState.Disconnected;
if (exception != null)
GlobalLogger.LogError("VoiceClient", "Connection lost", exception);
else
GlobalLogger.Log("VoiceClient", "Disconnected.");
if (wasConnected)
OnDisconnected?.Invoke(this);
}
/// Sends clientdisconnect to the server and closes the connection after a short drain delay.
public void Disconnect()
{
lock (_lock)
{
if (State != VoiceClientState.Connected)
return;
State = VoiceClientState.Disconnecting;
}
SendCommand("clientdisconnect", new Dictionary { ["reasonid"] = 8, ["reasonmsg"] = "leaving" });
Task.Delay(500).ContinueWith(_ => Stop(wait: false));
}
/// Closes the UDP socket to force an immediate reconnect attempt.
public void ForceReconnect()
{
try { _udp?.Close(); } catch { }
}
/// Sends a pre-encoded Opus audio frame.
public void SendVoice(byte[] opusData)
{
if (State != VoiceClientState.Connected)
return;
byte[] payload = new byte[3 + opusData.Length];
payload[2] = 5; // Opus Music codec
Buffer.BlockCopy(opusData, 0, payload, 3, opusData.Length);
SendOutgoing(payload, VoicePacketType.Voice);
}
/// Sends an end-of-stream voice marker.
public void SendVoiceStop()
{
if (State != VoiceClientState.Connected)
return;
SendOutgoing(new byte[] { 0, 0, 5 }, VoicePacketType.Voice);
}
/// Sends a text message. Returns false if not connected.
public bool SendTextMessage(MessageTargetMode targetmode, int target, string text)
{
if (State != VoiceClientState.Connected) return false;
SendCommand($"sendtextmessage targetmode={(int)targetmode} target={target} msg={text.ToTsMessageFormat()}");
return true;
}
/// Sends a text message to the channel this client is currently in.
public bool SendChannelMessage(string text)
=> CurrentChannelId > 0 && SendTextMessage(MessageTargetMode.Channel, CurrentChannelId, text);
/// Sends a raw command string to the server.
public void SendCommand(string cmd, Dictionary parameters)
=> SendCommand(TsEncoding.Build(cmd, parameters));
/// Sends a raw command string to the server.
public void SendCommand(string cmd, Dictionary parameters)
=> SendCommand(TsEncoding.Build(cmd, parameters));
/// Sends a raw command string to the server.
public void SendCommand(string cmd)
{
byte[] data = Encoding.UTF8.GetBytes(cmd);
if (data.Length <= MaxOutContent)
{
SendOutgoing(data, VoicePacketType.Command);
return;
}
// Fragment large commands — FLAG_FRAGMENTED on first and last chunk only
int chunkCount = (data.Length + MaxOutContent - 1) / MaxOutContent;
for (int i = 0; i < chunkCount; i++)
{
int offset = i * MaxOutContent;
int len = Math.Min(MaxOutContent, data.Length - offset);
byte[] chunk = new byte[len];
Buffer.BlockCopy(data, offset, chunk, 0, len);
byte extraFlags = (i == 0 || i == chunkCount - 1) ? FlagFragmented : (byte)0;
SendOutgoing(chunk, VoicePacketType.Command, extraFlags);
}
}
private byte[] BuildInit0()
{
byte[] buf = new byte[4 + 1 + 4 + 4 + 8];
WriteUInt32BE(buf, 0, VoiceCrypto.InitVersion);
buf[4] = 0x00; // step 0
WriteUInt32BE(buf, 5, (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds());
RandomNumberGenerator.Fill(buf.AsSpan(9, 4));
return buf;
}
private void SendInitPacket(byte[] data)
{
byte[] raw = BuildRawPacket(data, VoicePacketType.Init1, 101, 0, FlagUnencrypted);
VoiceCrypto.InitMac.CopyTo(raw, 0); // overwrite MAC with TS3INIT1
lock (_lock)
{
_initResend = (raw, Environment.TickCount64, Environment.TickCount64);
}
SendRaw(raw);
}
private void SendOutgoing(byte[] data, VoicePacketType type, byte extraFlags = 0)
{
ushort id;
uint gen;
lock (_lock)
{
(id, gen) = GetOutCounter(type);
IncrementOutCounter(type);
}
byte flags = extraFlags;
switch (type)
{
case VoicePacketType.Voice:
case VoicePacketType.VoiceWhisper:
data[0] = (byte)(id >> 8);
data[1] = (byte)id;
break;
case VoicePacketType.Command:
case VoicePacketType.CommandLow:
flags |= FlagNewProtocol;
break;
case VoicePacketType.Ping:
case VoicePacketType.Pong:
flags |= FlagUnencrypted;
break;
}
byte[] raw = BuildRawPacket(data, type, id, gen, flags);
EncryptPacket(raw, type, id, gen, flags, data);
if (type == VoicePacketType.Command || type == VoicePacketType.CommandLow)
{
lock (_lock)
_resendMap[id] = (raw, Environment.TickCount64, Environment.TickCount64);
}
SendRaw(raw);
}
private byte[] BuildRawPacket(byte[] data, VoicePacketType type, ushort packetId, uint gen, byte flags)
{
int cid;
lock (_lock) cid = ClientId;
byte[] raw = new byte[VoiceCrypto.MacLen + C2sHeaderLen + data.Length];
raw[VoiceCrypto.MacLen + 0] = (byte)(packetId >> 8);
raw[VoiceCrypto.MacLen + 1] = (byte)packetId;
raw[VoiceCrypto.MacLen + 2] = (byte)(cid >> 8);
raw[VoiceCrypto.MacLen + 3] = (byte)cid;
raw[VoiceCrypto.MacLen + 4] = (byte)((flags & 0xf0) | ((byte)type & 0x0f));
Buffer.BlockCopy(data, 0, raw, VoiceCrypto.MacLen + C2sHeaderLen, data.Length);
return raw;
}
private void EncryptPacket(byte[] raw, VoicePacketType type, ushort packetId, uint gen, byte flags, byte[] data)
{
if (type == VoicePacketType.Init1)
{
VoiceCrypto.InitMac.CopyTo(raw, 0);
return;
}
if ((flags & FlagUnencrypted) != 0)
{
lock (_lock) Buffer.BlockCopy(_fakeSignature, 0, raw, 0, VoiceCrypto.MacLen);
return;
}
byte[] key, nonce;
lock (_lock)
{
if (!_cryptoInitComplete)
{
key = VoiceCrypto.DummyKey;
nonce = VoiceCrypto.DummyNonce;
}
else
(key, nonce) = VoiceCrypto.DeriveKeyNonce(false, packetId, gen, (byte)type, _ivStruct);
}
var header = raw.AsSpan(VoiceCrypto.MacLen, C2sHeaderLen).ToArray();
var (ciphertext, mac) = VoiceCrypto.EaxEncrypt(key, nonce, header, data);
Buffer.BlockCopy(mac, 0, raw, 0, VoiceCrypto.MacLen);
Buffer.BlockCopy(ciphertext, 0, raw, VoiceCrypto.MacLen + C2sHeaderLen, ciphertext.Length);
}
private void SendRaw(byte[] raw)
{
UdpClient udp;
lock (_lock) udp = _udp;
if (udp == null || State == VoiceClientState.Disconnected)
return;
try
{
udp.Send(raw, raw.Length, _remoteEndpoint);
}
catch (Exception ex)
{
GlobalLogger.LogError("VoiceClient", "Send failed", ex);
OnError?.Invoke(this, ex);
}
}
private void HandleIncomingPacket(byte[] raw)
{
if (raw.Length < VoiceCrypto.MacLen + S2cHeaderLen)
return;
ushort packetId = (ushort)((raw[VoiceCrypto.MacLen] << 8) | raw[VoiceCrypto.MacLen + 1]);
byte ptByte = raw[VoiceCrypto.MacLen + 2];
var type = (VoicePacketType)(ptByte & 0x0f);
byte flags = (byte)(ptByte & 0xf0);
byte[] data = DecryptPacket(raw, type, packetId, flags);
if (data == null)
{
GlobalLogger.Log("VoiceClient", $"Failed to decrypt packet type={type} id={packetId}", LogType.Verbose);
return;
}
switch (type)
{
case VoicePacketType.Init1:
HandleInit(data);
break;
case VoicePacketType.Command:
case VoicePacketType.CommandLow:
SendAck(packetId, type == VoicePacketType.Command ? VoicePacketType.Ack : VoicePacketType.AckLow);
HandleCommandData(data, flags);
break;
case VoicePacketType.Ack:
case VoicePacketType.AckLow:
HandleAck(data);
break;
case VoicePacketType.Ping:
HandlePing(packetId);
break;
case VoicePacketType.Voice:
case VoicePacketType.VoiceWhisper:
OnVoiceData?.Invoke(this, data);
break;
}
}
private byte[] DecryptPacket(byte[] raw, VoicePacketType type, ushort packetId, byte flags)
{
if (type == VoicePacketType.Init1)
{
if (!SpanEquals(raw.AsSpan(0, VoiceCrypto.MacLen), VoiceCrypto.InitMac))
return null;
return raw.AsSpan(VoiceCrypto.MacLen + S2cHeaderLen).ToArray();
}
if ((flags & FlagUnencrypted) != 0)
{
lock (_lock)
{
if (_cryptoInitComplete && !SpanEquals(raw.AsSpan(0, VoiceCrypto.MacLen), _fakeSignature))
return null;
}
return raw.AsSpan(VoiceCrypto.MacLen + S2cHeaderLen).ToArray();
}
uint gen;
lock (_lock) gen = IncrementInCounter(type, packetId);
// Try real key, then dummy as fallback (handles transition packets)
return TryDecrypt(raw, type, packetId, gen, forceDummy: false)
?? TryDecrypt(raw, type, packetId, gen, forceDummy: true);
}
private byte[] TryDecrypt(byte[] raw, VoicePacketType type, ushort packetId, uint gen, bool forceDummy)
{
byte[] key, nonce;
lock (_lock)
{
if (forceDummy || !_cryptoInitComplete)
{
key = VoiceCrypto.DummyKey;
nonce = VoiceCrypto.DummyNonce;
}
else
{
(key, nonce) = VoiceCrypto.DeriveKeyNonce(true, packetId, gen, (byte)type, _ivStruct);
}
}
return VoiceCrypto.EaxDecrypt(key, nonce,
raw.AsSpan(VoiceCrypto.MacLen, S2cHeaderLen),
raw.AsSpan(VoiceCrypto.MacLen + S2cHeaderLen),
raw.AsSpan(0, VoiceCrypto.MacLen));
}
private void SendAck(ushort ackId, VoicePacketType ackType)
{
byte[] buf = { (byte)(ackId >> 8), (byte)ackId };
SendOutgoing(buf, ackType);
}
private void HandlePing(ushort packetId)
{
byte[] buf = { (byte)(packetId >> 8), (byte)packetId };
SendOutgoing(buf, VoicePacketType.Pong);
}
private void HandleAck(byte[] data)
{
if (data.Length < 2)
return;
ushort ackedId = (ushort)((data[0] << 8) | data[1]);
lock (_lock) _resendMap.Remove(ackedId);
}
private void HandleInit(byte[] data)
{
if (data.Length < 1)
return;
byte step = data[0];
switch (step)
{
case 1:
{
if (data.Length < 21)
return;
byte[] init2 = new byte[4 + 1 + 16 + 4];
WriteUInt32BE(init2, 0, VoiceCrypto.InitVersion);
init2[4] = 0x02;
Buffer.BlockCopy(data, 1, init2, 5, 16);
lock (_lock) _initResend = null;
SendInitPacket(init2);
break;
}
case 3:
{
if (data.Length < 1 + 64 + 64 + 4 + 100)
return;
lock (_lock)
{
State = VoiceClientState.Handshake;
_initResend = null;
}
byte[] x = data.AsSpan(1, 64).ToArray();
byte[] n = data.AsSpan(65, 64).ToArray();
int level = (int)ReadUInt32BE(data, 129);
byte[] serverData = data.AsSpan(133, 100).ToArray();
// Solve: y = x^(2^level) mod n
var xBig = new BigInteger(x, isUnsigned: true, isBigEndian: true);
var nBig = new BigInteger(n, isUnsigned: true, isBigEndian: true);
var yBig = BigInteger.ModPow(xBig, BigInteger.Pow(2, level), nBig);
// Pad y to 64 bytes (matching x/n size)
byte[] y64 = new byte[64];
byte[] yRaw = yBig.ToByteArray(isUnsigned: true, isBigEndian: true);
int yDstOff = yRaw.Length < 64 ? 64 - yRaw.Length : 0;
Buffer.BlockCopy(yRaw, 0, y64, yDstOff, Math.Min(yRaw.Length, 64));
byte[] alphaTmp = RandomNumberGenerator.GetBytes(10);
string alpha = Convert.ToBase64String(alphaTmp);
string omega = _identity.PublicKeyString;
string initAdd = TsEncoding.Build("clientinitiv", new Dictionary
{
["alpha"] = alpha,
["omega"] = omega,
["ot"] = 1,
["ip"] = ""
});
byte[] textBytes = Encoding.UTF8.GetBytes(initAdd);
byte[] init4 = new byte[4 + 1 + 64 + 64 + 4 + 100 + 64 + textBytes.Length];
WriteUInt32BE(init4, 0, VoiceCrypto.InitVersion);
init4[4] = 0x04;
Buffer.BlockCopy(x, 0, init4, 5, 64);
Buffer.BlockCopy(n, 0, init4, 69, 64);
WriteUInt32BE(init4, 133, (uint)level);
Buffer.BlockCopy(serverData, 0, init4, 137, 100);
Buffer.BlockCopy(y64, 0, init4, 237, 64);
Buffer.BlockCopy(textBytes, 0, init4, 301, textBytes.Length);
lock (_lock) _alphaTmp = alphaTmp;
SendInitPacket(init4);
break;
}
case 0x7f:
{
lock (_lock) _initResend = null;
SendInitPacket(BuildInit0());
break;
}
}
}
private void HandleCommandData(byte[] data, byte flags)
{
if ((flags & FlagFragmented) != 0)
{
lock (_lock)
{
if (!_fragmenting)
{
_fragmenting = true;
_fragmentFlags = flags;
_fragmentBuffer.Clear();
_fragmentBuffer.Add((byte[])data.Clone());
}
else
{
_fragmentBuffer.Add((byte[])data.Clone());
// Reassemble
int totalLen = 0;
foreach (var f in _fragmentBuffer) totalLen += f.Length;
byte[] merged = new byte[totalLen];
int pos = 0;
foreach (var f in _fragmentBuffer) { Buffer.BlockCopy(f, 0, merged, pos, f.Length); pos += f.Length; }
byte savedFlags = _fragmentFlags;
_fragmenting = false;
_fragmentBuffer.Clear();
ProcessCommand(merged, savedFlags);
}
}
return;
}
lock (_lock)
{
if (_fragmenting)
{
_fragmentBuffer.Add((byte[])data.Clone());
return;
}
}
ProcessCommand(data, flags);
}
private void ProcessCommand(byte[] data, byte flags)
{
if ((flags & 0x40) != 0) // FLAG_COMPRESSED
{
try { data = QuickLz.Decompress(data); }
catch (Exception ex)
{
GlobalLogger.Log("VoiceClient", $"Decompression failed: {ex.Message}", LogType.Warning);
return;
}
}
string cmdStr = Encoding.UTF8.GetString(data);
#if DEBUG_QUERY_NOTIFY && !DEBUG_QUERY_ALL
GlobalLogger.Log("VoiceClient_DEBUG", $"Read: {cmdStr}", LogType.Verbose);
#endif
_commandQueue.Enqueue(TsEncoding.Parse(cmdStr));
}
private async Task commandLoop(CancellationToken ct)
{
if (!_commandQueue.TryDequeue(out TsMessage message))
{
await Task.Delay(1, ct);
return;
}
OnCommand?.Invoke(this, message);
switch (message.Name)
{
case "initivexpand": HandleInitIvExpand(message.Params); break;
case "initivexpand2": HandleInitIvExpand2(message.Params); break;
case "initserver": HandleInitServer(message.Params); break;
case "channellist": HandleChannelList(message); break;
case "channellistfinished": HandleChannelListFinished(); break;
case "notifyclientmoved":
if (message.Params.TryGetValue(out var movedClid, "clid") &&
int.TryParse(movedClid, out int movedId) && movedId == ClientId &&
message.Params.TryGetValue(out var ctidStr, "ctid") &&
int.TryParse(ctidStr, out int ctid))
{
CurrentChannelId = ctid;
OnChannelChanged?.Invoke(this, ctid);
}
break;
case "notifyclientleftview":
if (message.Params.TryGetValue(out var clid, "clid") &&
int.TryParse(clid, out int leftId) && leftId == ClientId &&
State != VoiceClientState.Disconnecting)
{
bool banned = message.Params.TryGetValue(out var rid, "reasonid") && rid == "6";
if (banned)
Disconnect(); // banned — stop cleanly, don't reconnect
else
ForceReconnect(); // kicked or server-side timeout — try again
}
break;
case "notifytextmessage":
OnTextMessage?.Invoke(this, new TextMessageArgs(message.Params));
break;
case "error":
{
if (!message.Params.TryGetValue(out var idStr, "id"))
break;
int errId = int.Parse(idStr);
if (errId == 0)
break; // success
string msg = message.Params.TryGetValue(out var m, "msg") ? m : "unknown";
OnError?.Invoke(this, new Exception($"TS3 error {errId}: {msg}"));
if (errId == 3329) // banned — fatal
Disconnect();
break;
}
}
}
private void commandLoopStop(LoopTask sender, Exception exception)
{
if (exception != null)
{
GlobalLogger.LogError("VoiceClient", "Command loop error", exception);
if (State != VoiceClientState.Connected)
ForceReconnect(); // trigger main loop to reconnect if error was during handshake
}
else
_commandQueue.Clear();
}
private void HandleInitIvExpand(TsDataEntry p)
{
lock (_lock) _initResend = null;
if (!p.TryGetValue(out var alpha, "alpha") ||
!p.TryGetValue(out var beta, "beta") ||
!p.TryGetValue(out var omega, "omega"))
{
var ex = new Exception("Missing initivexpand parameters.");
OnError?.Invoke(this, ex);
ForceReconnect();
return;
}
byte[] alphaBytes = Convert.FromBase64String(alpha);
byte[] betaBytes = Convert.FromBase64String(beta);
byte[] omegaBytes = Convert.FromBase64String(omega);
byte[] sharedKey = _identity.GetSharedSecret(omegaBytes);
// ivStruct = sharedKey[0..9] XOR alpha[0..9] || sharedKey[10..] XOR beta
byte[] iv = new byte[10 + betaBytes.Length];
Buffer.BlockCopy(sharedKey, 0, iv, 0, 10);
VoiceCrypto.XorInto(iv, alphaBytes, 10);
for (int i = 0; i < betaBytes.Length; i++)
iv[10 + i] = (byte)(sharedKey[10 + i] ^ betaBytes[i]);
byte[] sig = SHA1.HashData(iv);
lock (_lock)
{
_ivStruct = iv;
Buffer.BlockCopy(sig, 0, _fakeSignature, 0, VoiceCrypto.MacLen);
_cryptoInitComplete = true;
_alphaTmp = null;
}
SendClientInit();
}
private void HandleInitIvExpand2(TsDataEntry p)
{
byte[] alphaTmp;
lock (_lock)
{
if (_cryptoInitComplete || _alphaTmp == null)
{
return;
}
_initResend = null;
alphaTmp = _alphaTmp;
}
if (!p.TryGetValue(out var l, "l") ||
!p.TryGetValue(out var betaB64, "beta"))
{
var ex = new Exception("Missing initivexpand2 parameters.");
OnError?.Invoke(this, ex);
ForceReconnect();
return;
}
try
{
byte[] licenseBytes = Convert.FromBase64String(l);
byte[] betaBytes = Convert.FromBase64String(betaB64);
var blocks = VoiceLicense.ParseLicense(licenseBytes);
byte[] serverKey = VoiceLicense.DeriveLicenseKey(blocks);
var (tempPub, tempPriv) = VoiceLicense.GenerateTemporaryKey();
// Proof: P-256 ECDSA signature of (tempPub || beta) with the identity key
byte[] toSign = new byte[tempPub.Length + betaBytes.Length];
Buffer.BlockCopy(tempPub, 0, toSign, 0, tempPub.Length);
Buffer.BlockCopy(betaBytes, 0, toSign, tempPub.Length, betaBytes.Length);
SendCommand("clientek", new Dictionary
{
["ek"] = Convert.ToBase64String(tempPub),
["proof"] = Convert.ToBase64String(_identity.Sign(toSign)),
});
// ivStruct = SHA-512(scalar·serverKey) [64 bytes]
byte[] iv = VoiceLicense.GetSharedSecret2(serverKey, tempPriv);
// XOR first 10 bytes with alphaTmp, remaining bytes with beta
VoiceCrypto.XorInto(iv, alphaTmp, 10);
for (int i = 0; i < betaBytes.Length; i++)
iv[10 + i] ^= betaBytes[i];
byte[] sig = SHA1.HashData(iv);
lock (_lock)
{
_ivStruct = iv;
Buffer.BlockCopy(sig, 0, _fakeSignature, 0, VoiceCrypto.MacLen);
_cryptoInitComplete = true;
_alphaTmp = null;
}
SendClientInit();
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
ForceReconnect();
}
}
private void SendClientInit()
{
string cmd = TsEncoding.Build("clientinit", new Dictionary
{
["client_nickname"] = _nickname,
["client_version"] = VersionSignVersion,
["client_platform"] = VersionSignPlatform,
["client_input_hardware"] = 1,
["client_output_hardware"] = 1,
["client_default_channel"] = _opts.DefaultChannel ?? "",
["client_default_channel_password"] = _opts.ChannelPassword != null
? VoiceCrypto.HashPassword(_opts.ChannelPassword)
: "",
["client_server_password"] = _opts.ServerPassword != null
? VoiceCrypto.HashPassword(_opts.ServerPassword)
: "",
["client_meta_data"] = "",
["client_version_sign"] = VersionSignSign,
["client_key_offset"] = _identity.KeyOffset.ToString(),
["client_nickname_phonetic"] = "",
["client_default_token"] = "",
["hwid"] = "",
});
SendCommand(cmd);
}
private void HandleInitServer(TsDataEntry p)
{
int cid = p.GetValue("aclid");
lock (_lock)
{
ClientId = cid;
State = VoiceClientState.Connected;
}
GlobalLogger.Log("VoiceClient", "Connected.");
OnConnected?.Invoke(this);
}
private void HandleChannelList(TsMessage parsed)
{
lock (_lock)
{
foreach (var e in parsed.Entries)
{
if (e.TryGetValue(out var cidStr, "cid") && int.TryParse(cidStr, out int cid) &&
e.TryGetValue(out var name, "channel_name") && !string.IsNullOrEmpty(name))
_channelMap[name] = cid;
}
}
}
private void HandleChannelListFinished()
{
string target = _opts.DefaultChannel;
if (string.IsNullOrEmpty(target))
return;
int cid;
lock (_lock)
{
if (int.TryParse(target, out int numericCid))
cid = numericCid;
else if (!_channelMap.TryGetValue(target, out cid))
cid = 0;
}
if (cid == 0)
{
GlobalLogger.Log("VoiceClient", $"Default channel \"{target}\" not found.", LogType.Warning);
return;
}
SendCommand("clientmove", new Dictionary
{
["cid"] = cid,
["clid"] = ClientId,
["cpw"] = _opts.ChannelPassword ?? "",
});
}
private async Task resendLoop(CancellationToken ct)
{
await Task.Delay(100, ct);
long now = Environment.TickCount64;
if (State == VoiceClientState.Connected && now - _lastPingTick >= 1000)
{
SendOutgoing(Array.Empty(), VoicePacketType.Ping);
_lastPingTick = now;
}
lock (_lock)
{
if (_initResend.HasValue)
{
var ir = _initResend.Value;
if (now - ir.firstSend > 30000)
{
var ex = new Exception("Init timeout.");
GlobalLogger.LogError("VoiceClient", "Init timed out", ex);
OnError?.Invoke(this, ex);
goto cleanup;
}
if (now - ir.lastSend > 1000)
{
_initResend = (ir.raw, ir.firstSend, now);
SendRaw(ir.raw);
}
}
var timedOut = new List();
foreach (var kv in _resendMap)
{
if (now - kv.Value.firstSend > 30000)
timedOut.Add(kv.Key);
else if (now - kv.Value.lastSend > 1000)
{
_resendMap[kv.Key] = (kv.Value.raw, kv.Value.firstSend, now);
SendRaw(kv.Value.raw);
}
}
if (timedOut.Count > 0)
{
foreach (var id in timedOut) _resendMap.Remove(id);
var ex = new Exception($"Packet(s) {string.Join(",", timedOut)} timed out.");
GlobalLogger.LogError("VoiceClient", "Packet ACK timeout", ex);
OnError?.Invoke(this, ex);
goto cleanup;
}
if (now - _lastMessageTick > 30000)
{
var ex = new Exception("Connection timeout — no response from server.");
GlobalLogger.LogError("VoiceClient", "Connection timed out", ex);
OnError?.Invoke(this, ex);
goto cleanup;
}
return;
cleanup:;
}
ForceReconnect();
}
private void resendLoopStop(LoopTask sender, Exception exception)
{
if (exception != null)
GlobalLogger.LogError("VoiceClient", "Resend loop error", exception);
}
private void ResetState()
{
State = VoiceClientState.Init;
ClientId = 0;
CurrentChannelId = 0;
_cryptoInitComplete = false;
_ivStruct = null;
Array.Clear(_fakeSignature, 0, _fakeSignature.Length);
_alphaTmp = null;
Array.Clear(_outCounters, 0, _outCounters.Length);
Array.Clear(_outGenerations, 0, _outGenerations.Length);
Array.Clear(_inCounters, 0, _inCounters.Length);
Array.Clear(_inGenerations, 0, _inGenerations.Length);
_resendMap.Clear();
_initResend = null;
_fragmentBuffer.Clear();
_fragmenting = false;
_fragmentFlags = 0;
_channelMap.Clear();
_lastPingTick = 0;
}
private (ushort id, uint gen) GetOutCounter(VoicePacketType type)
{
if (type == VoicePacketType.Init1)
return (101, 0);
int i = (int)type;
return (_outCounters[i], _outGenerations[i]);
}
private void IncrementOutCounter(VoicePacketType type)
{
if (type == VoicePacketType.Init1)
return;
int i = (int)type;
_outCounters[i]++;
if (_outCounters[i] == 0) _outGenerations[i]++;
}
private uint IncrementInCounter(VoicePacketType type, ushort packetId)
{
int i = (int)type;
if (_inCounters[i] > 0xFF00 && packetId < 0x0100)
_inGenerations[i]++;
_inCounters[i] = packetId;
return _inGenerations[i];
}
private static void WriteUInt32BE(byte[] buf, int offset, uint value)
{
buf[offset + 0] = (byte)(value >> 24);
buf[offset + 1] = (byte)(value >> 16);
buf[offset + 2] = (byte)(value >> 8);
buf[offset + 3] = (byte)value;
}
private static uint ReadUInt32BE(byte[] buf, int offset)
=> ((uint)buf[offset] << 24) | ((uint)buf[offset + 1] << 16)
| ((uint)buf[offset + 2] << 8) | buf[offset + 3];
private static bool SpanEquals(ReadOnlySpan a, ReadOnlySpan b)
=> a.SequenceEqual(b);
}
}