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