using System; using System.Collections.Generic; using System.Numerics; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; namespace axXez.TS3.Protocol { /// TS3 P-256 identity used for voice client authentication. public sealed class VoiceIdentity : IDisposable { private readonly ECDsa _signer; private readonly ECDiffieHellman _ecdh; private readonly byte[] _pubKeyBytes; private BigInteger _keyOffset; private int _securityLevel; /// Base64-encoded libtomcrypt public key (the omega parameter). public string PublicKeyString { get; } /// Security level offset (client_key_offset). public BigInteger KeyOffset => _keyOffset; /// Current security level of this identity (leading zero bits of SHA-1(publicKey + KeyOffset)). Cached; updates when KeyOffset changes via . public int SecurityLevel => _securityLevel; /// base64(SHA-1(PublicKeyString)). public string Uid { get; } private VoiceIdentity(ECDsa signer, ECDiffieHellman ecdh, string pubKeyString, BigInteger keyOffset) { _signer = signer; _ecdh = ecdh; PublicKeyString = pubKeyString; _pubKeyBytes = Encoding.ASCII.GetBytes(pubKeyString); Uid = Convert.ToBase64String(SHA1.HashData(_pubKeyBytes)); _keyOffset = keyOffset; _securityLevel = GetSecurityLevel(_pubKeyBytes, keyOffset); } /// Signs data with ECDSA P-256 / SHA-256. Returns DER-encoded signature. public byte[] Sign(byte[] data) => _signer.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); /// Derives the shared secret from the server's libtomcrypt public key DER. /// SHA-1 of the x-coordinate of the ECDH shared point (20 bytes). public byte[] GetSharedSecret(byte[] serverLtcDer) { var (x, y, _, _) = ParseLtcDer(serverLtcDer); using var serverEcdh = ECDiffieHellman.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = BigIntToBytes32(x), Y = BigIntToBytes32(y) } }); byte[] sharedX = _ecdh.DeriveRawSecretAgreement(serverEcdh.PublicKey); // Normalise to 32 bytes (P-256 x-coordinate) byte[] key32; if (sharedX.Length == 32) { key32 = sharedX; } else if (sharedX.Length > 32) { key32 = new byte[32]; Buffer.BlockCopy(sharedX, sharedX.Length - 32, key32, 0, 32); } else { key32 = new byte[32]; Buffer.BlockCopy(sharedX, 0, key32, 32 - sharedX.Length, sharedX.Length); } return SHA1.HashData(key32); } /// Imports an identity from the TS3 client export format: 0V<base64>. public static VoiceIdentity FromTsIdentity(string identityStr) { var m = Regex.Match(identityStr, @"^(\d+)V([\w/+=]+)$"); if (!m.Success) throw new FormatException("Invalid TS3 identity format."); var keyOffset = BigInteger.Parse(m.Groups[1].Value); byte[] ident = Convert.FromBase64String(m.Groups[2].Value); if (ident.Length < 20) throw new FormatException("Identity data too short."); // Locate null terminator after offset 20 to find hash region length int nullIdx = -1; for (int i = 20; i < ident.Length; i++) if (ident[i] == 0) { nullIdx = i - 20; break; } int hashLen = nullIdx < 0 ? ident.Length - 20 : nullIdx; // Deobfuscate: XOR first 20 bytes with SHA-1 of the data after offset 20, // then XOR up to 100 bytes with the static obfuscation key byte[] h = SHA1.HashData(new ReadOnlySpan(ident, 20, hashLen)); VoiceCrypto.XorInto(ident, h, 20); VoiceCrypto.XorInto(ident, ObfuscationKey, Math.Min(100, ident.Length)); // Result is base64-encoded ASN.1 DER as UTF-8 bytes; strip null terminator string innerB64 = Encoding.UTF8.GetString(ident).TrimEnd('\0'); byte[] asn = Convert.FromBase64String(innerB64); return ImportFromAsn(asn, keyOffset); } /// Exports the identity to the TS3 client format: {KeyOffset}V{base64}. Round-trips with . public string ToTsIdentity() { var ecParams = _signer.ExportParameters(includePrivateParameters: true); var x = new BigInteger(ecParams.Q.X, isUnsigned: true, isBigEndian: true); var y = new BigInteger(ecParams.Q.Y, isUnsigned: true, isBigEndian: true); var d = new BigInteger(ecParams.D, isUnsigned: true, isBigEndian: true); // Build libtomcrypt DER with full key (bitInfo = 0x80: public + private) byte[] bitStr = { 0x03, 0x02, 0x07, 0x80 }; byte[] int32 = BuildDerInteger(32); byte[] intX = BuildDerInteger(x); byte[] intY = BuildDerInteger(y); byte[] intD = BuildDerInteger(d); int contentLen = bitStr.Length + int32.Length + intX.Length + intY.Length + intD.Length; byte[] lenBytes = BuildDerLength(contentLen); byte[] asn = new byte[1 + lenBytes.Length + contentLen]; int p = 0; asn[p++] = 0x30; lenBytes.CopyTo(asn, p); p += lenBytes.Length; bitStr.CopyTo(asn, p); p += bitStr.Length; int32.CopyTo(asn, p); p += int32.Length; intX.CopyTo(asn, p); p += intX.Length; intY.CopyTo(asn, p); p += intY.Length; intD.CopyTo(asn, p); // UTF-8 bytes of the base64-encoded DER (the unobfuscated inner payload) byte[] ident = Encoding.UTF8.GetBytes(Convert.ToBase64String(asn)); // Obfuscate: exact reverse of the deobfuscation in FromTsIdentity // Step 1 reversal: undo the ObfuscationKey XOR so ident[20..] is back to obfuscated state VoiceCrypto.XorInto(ident, ObfuscationKey, Math.Min(100, ident.Length)); // Step 2 reversal: hash the now-obfuscated ident[20..] the same way import would int hashLen = ident.Length - 20; for (int i = 20; i < ident.Length; i++) if (ident[i] == 0) { hashLen = i - 20; break; } byte[] h = SHA1.HashData(new ReadOnlySpan(ident, 20, hashLen)); VoiceCrypto.XorInto(ident, h, 20); return $"{KeyOffset}V{Convert.ToBase64String(ident)}"; } /// Generates a new random identity and improves it to the requested security level. public static VoiceIdentity Generate(int securityLevel = 8) { using var tmp = ECDsa.Create(ECCurve.NamedCurves.nistP256); var identity = CreateFromEcParams(tmp.ExportParameters(includePrivateParameters: true), BigInteger.Zero); if (securityLevel > 0) identity.ImproveSecurity(securityLevel); return identity; } /// Brute-forces KeyOffset until the identity reaches the requested security level. public void ImproveSecurity(int targetLevel) { BigInteger offset = _keyOffset; int best = _securityLevel; for (BigInteger i = offset; best < targetLevel; i++) { int curr = GetSecurityLevel(_pubKeyBytes, i); if (curr > best) { offset = i; best = curr; } } _keyOffset = offset; _securityLevel = best; } private static int GetSecurityLevel(byte[] pubKeyBytes, BigInteger offset) { byte[] offsetBytes = Encoding.ASCII.GetBytes(offset.ToString()); byte[] input = new byte[pubKeyBytes.Length + offsetBytes.Length]; Buffer.BlockCopy(pubKeyBytes, 0, input, 0, pubKeyBytes.Length); Buffer.BlockCopy(offsetBytes, 0, input, pubKeyBytes.Length, offsetBytes.Length); return CountLeadingZeroBits(SHA1.HashData(input)); } private static int CountLeadingZeroBits(byte[] data) { int count = 0; foreach (byte b in data) { if (b == 0) { count += 8; continue; } for (int bit = 0; bit < 8; bit++) { if ((b & (1 << bit)) == 0) count++; else return count; } } return count; } /// Encodes the public key in libtomcrypt format for the omega/server parameters. public static byte[] BuildLtcPublicKeyDer(BigInteger x, BigInteger y) { // SEQUENCE { BIT_STRING(flags=0x00), INTEGER(32), INTEGER(x), INTEGER(y) } byte[] bitStr = { 0x03, 0x02, 0x07, 0x00 }; byte[] int32 = BuildDerInteger(32); byte[] intX = BuildDerInteger(x); byte[] intY = BuildDerInteger(y); int contentLen = bitStr.Length + int32.Length + intX.Length + intY.Length; byte[] lenBytes = BuildDerLength(contentLen); byte[] result = new byte[1 + lenBytes.Length + contentLen]; int pos = 0; result[pos++] = 0x30; lenBytes.CopyTo(result, pos); pos += lenBytes.Length; bitStr.CopyTo(result, pos); pos += bitStr.Length; int32.CopyTo(result, pos); pos += int32.Length; intX.CopyTo(result, pos); pos += intX.Length; intY.CopyTo(result, pos); return result; } // Returns (x, y, d_or_null, bitInfo) private static (BigInteger x, BigInteger y, BigInteger? d, int bitInfo) ParseLtcDer(byte[] data) { int pos = 0; if (data[pos++] != 0x30) throw new FormatException("Expected SEQUENCE."); var (seqLen, seqLenBytes) = ReadDerLength(data, pos); pos += seqLenBytes; int end = pos + seqLen; var values = new List(); while (pos < end) { byte tag = data[pos++]; var (len, lb) = ReadDerLength(data, pos); pos += lb; if (tag == 0x03) { values.Add((int)data[pos + 1]); // skip unused-bits byte, read flags byte } else if (tag == 0x02) { values.Add(new BigInteger(new ReadOnlySpan(data, pos, len), isUnsigned: true, isBigEndian: true)); } pos += len; } if (values.Count < 3) throw new FormatException("Insufficient elements in libtomcrypt DER."); int bitInfo = (int)values[0]; // values[1] = key size (32) — skipped if (bitInfo == 0x00 || bitInfo == 0x80) { var x = (BigInteger)values[2]; var y = values.Count > 3 ? (BigInteger)values[3] : BigInteger.Zero; BigInteger? d = (bitInfo == 0x80 && values.Count >= 5) ? (BigInteger)values[4] : null; return (x, y, d, bitInfo); } if (bitInfo == 0xC0) { return (BigInteger.Zero, BigInteger.Zero, (BigInteger)values[2], bitInfo); } throw new FormatException($"Unknown libtomcrypt key flags: 0x{bitInfo:X2}"); } private static VoiceIdentity ImportFromAsn(byte[] asn, BigInteger keyOffset) { var (x, y, d, bitInfo) = ParseLtcDer(asn); if (d == null) throw new FormatException("Identity does not contain a private key."); ECParameters ecParams; if (bitInfo == 0x80) { ecParams = new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = BigIntToBytes32(x), Y = BigIntToBytes32(y) }, D = BigIntToBytes32(d.Value), }; } else // 0xC0 — only private scalar, derive public key { ecParams = DerivePublicKeyFromD(BigIntToBytes32(d.Value)); } return CreateFromEcParams(ecParams, keyOffset); } /// Derives EC public key from private scalar using ImportECPrivateKey with minimal SEC1 DER. private static ECParameters DerivePublicKeyFromD(byte[] dBytes) { // Build SEC1 ECPrivateKey with embedded curve OID so the platform can derive Q // OID for P-256 (prime256v1): 1.2.840.10045.3.1.7 byte[] curveOid = { 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07 }; byte[] oidTlv = new byte[2 + curveOid.Length]; oidTlv[0] = 0x06; oidTlv[1] = (byte)curveOid.Length; Buffer.BlockCopy(curveOid, 0, oidTlv, 2, curveOid.Length); // [0] EXPLICIT { OID } byte[] ctx0 = new byte[2 + oidTlv.Length]; ctx0[0] = 0xa0; ctx0[1] = (byte)oidTlv.Length; Buffer.BlockCopy(oidTlv, 0, ctx0, 2, oidTlv.Length); // OCTET STRING (private key) byte[] privOctet = new byte[2 + dBytes.Length]; privOctet[0] = 0x04; privOctet[1] = (byte)dBytes.Length; Buffer.BlockCopy(dBytes, 0, privOctet, 2, dBytes.Length); // version = 1 byte[] ver = { 0x02, 0x01, 0x01 }; int seqLen = ver.Length + privOctet.Length + ctx0.Length; byte[] sec1 = new byte[2 + seqLen]; sec1[0] = 0x30; sec1[1] = (byte)seqLen; int p = 2; Buffer.BlockCopy(ver, 0, sec1, p, ver.Length); p += ver.Length; Buffer.BlockCopy(privOctet, 0, sec1, p, privOctet.Length); p += privOctet.Length; Buffer.BlockCopy(ctx0, 0, sec1, p, ctx0.Length); using var ecDsa = ECDsa.Create(); ecDsa.ImportECPrivateKey(sec1, out _); return ecDsa.ExportParameters(includePrivateParameters: true); } private static VoiceIdentity CreateFromEcParams(ECParameters ecParams, BigInteger keyOffset) { var signer = ECDsa.Create(ecParams); var ecdh = ECDiffieHellman.Create(ecParams); var x = new BigInteger(ecParams.Q.X, isUnsigned: true, isBigEndian: true); var y = new BigInteger(ecParams.Q.Y, isUnsigned: true, isBigEndian: true); string pubKeyString = Convert.ToBase64String(BuildLtcPublicKeyDer(x, y)); return new VoiceIdentity(signer, ecdh, pubKeyString, keyOffset); } private static byte[] BuildDerInteger(BigInteger value) { byte[] bytes = value.ToByteArray(isUnsigned: true, isBigEndian: true); // Prepend 0x00 if high bit set (DER sign byte for positive integers) if ((bytes[0] & 0x80) != 0) { byte[] padded = new byte[bytes.Length + 1]; Buffer.BlockCopy(bytes, 0, padded, 1, bytes.Length); bytes = padded; } byte[] len = BuildDerLength(bytes.Length); byte[] result = new byte[1 + len.Length + bytes.Length]; result[0] = 0x02; len.CopyTo(result, 1); bytes.CopyTo(result, 1 + len.Length); return result; } private static byte[] BuildDerLength(int len) { if (len < 0x80) return new byte[] { (byte)len }; if (len < 0x100) return new byte[] { 0x81, (byte)len }; return new byte[] { 0x82, (byte)(len >> 8), (byte)len }; } private static (int length, int bytesConsumed) ReadDerLength(byte[] data, int pos) { if (data[pos] < 0x80) return (data[pos], 1); int numBytes = data[pos] & 0x7f; int len = 0; for (int i = 0; i < numBytes; i++) len = (len << 8) | data[pos + 1 + i]; return (len, 1 + numBytes); } public static byte[] BigIntToBytes32(BigInteger n) { byte[] raw = n.ToByteArray(isUnsigned: true, isBigEndian: true); if (raw.Length == 32) return raw; byte[] padded = new byte[32]; int srcOff = raw.Length > 32 ? raw.Length - 32 : 0; int dstOff = raw.Length < 32 ? 32 - raw.Length : 0; Buffer.BlockCopy(raw, srcOff, padded, dstOff, Math.Min(raw.Length, 32)); return padded; } private static readonly byte[] ObfuscationKey = Encoding.ASCII.GetBytes( "b9dfaa7bee6ac57ac7b65f1094a1c155" + "e747327bc2fe5d51c512023fe54a2802" + "01004e90ad1daaae1075d53b7d571c30" + "e063b5a62a4a017bb394833aa0983e6e"); public void Dispose() { _signer?.Dispose(); _ecdh?.Dispose(); } } }