Sign in to myTeamSpeak and import what the account synchronises

The myTeamSpeak client protocol and its end-to-end encryption are
reverse-engineered (docs/myteamspeak): an HTTP POST of a framed protobuf,
a PBKDF2-HMAC-SHA512 login token and account key, an AES-GCM wrapped
item key and AES-CTR items with a SHA-512 trailer.

Options on desktop and Settings on Android gain a myTeamSpeak page: sign
in and out, and the account's bookmarks and identities, each picked for
import and marked when already here. The client stays signed in the way
the official one does, keeping the derived token and key rather than the
password; each read signs in afresh, and a sign-in the server no longer
takes signs out. Nothing is written to the account.

Items are the same Item_Data as the local TeamSpeak store, so they go
through the existing decoder and importer; a bookmark chosen alone brings
the identity it connects with, found by UUID or, as some name it, by
the identity's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 01:07:17 +00:00
parent 73a15a8cfc
commit a7aa122728
25 changed files with 3425 additions and 32 deletions

View File

@@ -12,10 +12,14 @@ import com.ts3client.config.Bookmarks
import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings
import com.ts3client.contacts.ContactStore
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.net.ChannelNode
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.sound.SoundNotifier
import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -27,6 +31,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
@@ -60,11 +65,12 @@ class SessionController(private val context: Context) {
val settings: Settings = Settings.load()
private val bookmarkStore = Bookmarks.load()
private val identities = IdentityStore.load(settings)
private val audio = AndroidAudioBackend(context)
private val soundPlayer = audio.createSoundPlayer(settings)
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
private val session = ServerSession(
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
settings, identities, audio, sounds, ContactStore.load(), Listener()
)
private val conn get() = session.connection()
private val network = NetworkFollower(context, session, ::refresh, ::system)
@@ -188,6 +194,24 @@ class SessionController(private val context: Context) {
_bookmarks.value = bookmarkStore.all().toList()
}
// ---- myTeamSpeak ----
/** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */
val myTeamSpeak: MyTeamSpeakLogin = MyTeamSpeakLogin.load()
private val importer = TeamSpeakImporter(identities, bookmarkStore, settings)
suspend fun signInMyTeamSpeak(email: String, password: String): MyTeamSpeak.Account =
withContext(Dispatchers.IO) { myTeamSpeak.signIn(email, password) }
suspend fun fetchMyTeamSpeak(): MyTeamSpeak.Account = withContext(Dispatchers.IO) { myTeamSpeak.fetch() }
fun isImported(item: SyncItem): Boolean = importer.isPresent(item)
/** Imports [chosen] out of the account's [all] items, with the identities the chosen bookmarks use. */
suspend fun importMyTeamSpeak(all: List<SyncItem>, chosen: List<SyncItem>): TeamSpeakImporter.Result =
withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
.also { _bookmarks.value = bookmarkStore.all().toList() }
// ---- the local client ----
fun hasMicPermission() =

View File

@@ -0,0 +1,221 @@
package com.ts3client.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ts3client.android.SessionController
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.myts.MyTsException
import com.ts3client.teamspeak.SyncItem
import kotlinx.coroutines.launch
import java.io.IOException
private data class ItemRow(val item: SyncItem, val imported: Boolean, val selected: Boolean)
/**
* The myTeamSpeak account: sign in and out, and pick what the account synchronises to
* import. Signing in lasts until signing out; the password itself is not kept.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: () -> Unit) {
val login = vm.myTeamSpeak
val scope = rememberCoroutineScope()
var signedIn by remember { mutableStateOf(login.isSignedIn) }
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf(false) }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var all by remember { mutableStateOf(emptyList<SyncItem>()) }
var rows by remember { mutableStateOf(emptyList<ItemRow>()) }
BackHandler(onBack = onBack)
fun show(account: MyTeamSpeak.Account) {
all = account.items()
rows = all.filter { it !is SyncItem.Folder }.map {
val imported = vm.isImported(it)
ItemRow(it, imported, !imported)
}
message = if (rows.isEmpty()) "The account holds no bookmarks or identities." else null
error = false
}
fun work(block: suspend () -> Unit) {
busy = true
message = null
scope.launch {
try {
block()
} catch (e: MyTsException) {
message = e.message
error = true
} catch (e: IOException) {
message = "Could not reach myTeamSpeak: ${e.message}"
error = true
}
// A sign-in the server no longer takes has signed out.
signedIn = login.isSignedIn
busy = false
}
}
LaunchedEffect(Unit) {
if (signedIn) work { show(vm.fetchMyTeamSpeak()) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("myTeamSpeak") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
actions = {
if (signedIn) {
IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) {
Icon(Icons.Filled.Refresh, "Refresh")
}
}
},
)
},
) { padding ->
LazyColumn(
Modifier.padding(padding).imePadding(),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
if (!signedIn) {
item {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sign in to bring over the bookmarks and identities your account synchronises. " +
"The app stays signed in; your password itself is not kept.")
OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") },
singleLine = true, enabled = !busy,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") },
singleLine = true, enabled = !busy, visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
Button(
onClick = {
work {
show(vm.signInMyTeamSpeak(email.trim(), password))
password = ""
}
},
enabled = !busy && email.isNotBlank() && password.isNotEmpty(),
) { Text("Sign in") }
}
}
} else {
item {
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Signed in as ${login.username()}", style = MaterialTheme.typography.titleMedium)
Text(login.email(), style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedButton(onClick = {
login.signOut()
signedIn = false
rows = emptyList()
all = emptyList()
message = null
}) { Text("Sign out") }
}
}
if (rows.isNotEmpty()) {
item {
Text("Synchronised by the account", Modifier.padding(start = 16.dp, top = 16.dp),
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
}
}
items(rows, key = { it.item.uuid() }) { row ->
val toggle = { rows = rows.map { if (it === row) it.copy(selected = !it.selected) else it } }
ListItem(
headlineContent = { Text(name(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(details(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingContent = { Checkbox(row.selected, { toggle() }) },
trailingContent = if (row.imported) ({ Text("Imported", style = MaterialTheme.typography.labelMedium) }) else null,
modifier = Modifier.clickable(onClick = toggle),
)
}
if (rows.isNotEmpty()) {
item {
Button(
onClick = {
work {
val r = vm.importMyTeamSpeak(all, rows.filter { it.selected }.map { it.item })
rows = rows.map { it.copy(imported = vm.isImported(it.item), selected = false) }
message = "Imported ${count(r.identitiesAdded(), "identity", "identities")} and " +
"${count(r.bookmarksAdded(), "bookmark", "bookmarks")}."
onImported()
}
},
enabled = !busy && rows.any { it.selected },
modifier = Modifier.padding(horizontal = 16.dp),
) { Text("Import selected") }
}
}
}
message?.let { text ->
item {
Text(text, Modifier.padding(horizontal = 16.dp),
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
}
}
}
}
}
private fun name(item: SyncItem) = when (item) {
is SyncItem.Bookmark -> item.name().ifBlank { item.address() }
is SyncItem.Identity -> "Identity: ${item.name()}"
else -> ""
}
private fun details(item: SyncItem) = when (item) {
is SyncItem.Bookmark -> "${item.address()}:${item.port()}"
is SyncItem.Identity -> item.nickname()
else -> ""
}
private fun count(n: Int, one: String, many: String) = "$n ${if (n == 1) one else many}"

View File

@@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -78,6 +79,12 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
vm.watchLevel(true)
onStopOrDispose { vm.watchLevel(false) }
}
var myTeamSpeak by rememberSaveable { mutableStateOf(false) }
if (myTeamSpeak) {
// The default identity's nickname may come along with an import.
MyTeamSpeakScreen(vm, onBack = { myTeamSpeak = false }, onImported = { nickname = settings.nickname })
return
}
val leave = {
if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() }
onBack()
@@ -178,6 +185,14 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
change { outputVolume = it.toDouble() }
}
Section("Account")
Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("myTeamSpeak")
Text(if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
else "Sign in to bring over your synchronised bookmarks and identities",
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}

View File

@@ -0,0 +1,198 @@
package com.ts3client.myts;
import com.ts3client.proto.ProtobufReader;
import com.ts3client.proto.ProtobufWriter;
import com.ts3client.teamspeak.SyncItem;
import com.ts3client.teamspeak.SyncItemDecoder;
import java.io.IOException;
import java.net.URI;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.List;
/**
* Reads the bookmarks and identities a myTeamSpeak account synchronises, the way
* the official client pulls them on a fresh install: sign in, ask for every item
* of the wanted classes against an empty local state, decrypt them, and sign out.
* Nothing is ever written to the account. Staying signed in is {@link MyTeamSpeakLogin}'s.
*
* <p>The protocol is described in {@code docs/myteamspeak/PROTOCOL.md}.
*/
public final class MyTeamSpeak {
public static final URI API = URI.create("https://clientapi.myteamspeak.com/");
/** What the account holds, in the same form as a local TeamSpeak 3 client's store. */
public record Account(String username, List<SyncItem> items) {
public Account {
items = List.copyOf(items);
}
public long identities() {
return items.stream().filter(SyncItem.Identity.class::isInstance).count();
}
public long bookmarks() {
return items.stream().filter(SyncItem.Bookmark.class::isInstance).count();
}
}
private static final String NO_VERSION = "00000000-0000-0000-0000-000000000000";
private static final int SYNC_VERSION_1_1 = 1;
private static final int[] ITEM_CLASSES = {0 /* BOOKMARK */, 1 /* IDENTITY */, 6 /* ITEM_FOLDER */};
private static final int LOGIN_OK = 200;
private static final int LOGIN_OFFLINE = 201;
private static final int LOGIN_EMAIL_PENDING = 203;
private static final int IN_SYNC = 300;
private static final int NOT_IN_DB = 302;
private final MyTsTransport transport;
public MyTeamSpeak() {
this(MyTsTransport.https(API));
}
MyTeamSpeak(MyTsTransport transport) {
this.transport = transport;
}
/**
* What it takes to sign in again, and all the client keeps of a sign-in — the same as
* the official client's {@code Account_Data}: the login token stands in for the
* password with the server, and the account key opens the item key it returns. The
* password itself is not needed again.
*/
public record Credentials(String email, String loginToken, byte[] accountKey) {
public Credentials {
accountKey = accountKey.clone();
}
@Override
public byte[] accountKey() {
return accountKey.clone();
}
/** Runs the key derivation, deliberately slow; call it off the UI thread. */
public static Credentials derive(String email, String password) throws MyTsException {
try {
return new Credentials(email, MyTsCrypto.loginToken(email, password),
MyTsCrypto.accountKey(email, password));
} catch (GeneralSecurityException e) {
throw new MyTsException("This platform lacks the ciphers myTeamSpeak needs", e);
}
}
}
/** Signs in, reads the account and signs out again: a few requests, so call it off the UI thread. */
public Account download(Credentials credentials) throws IOException, MyTsException {
String email = credentials.email();
String loginToken = credentials.loginToken();
byte[] accountKey = credentials.accountKey();
ProtobufReader reply = new ProtobufReader(transport.call("authentication", "login",
new ProtobufWriter().string(1, email).string(2, loginToken).toByteArray()));
byte[] keyPackage = null;
String session = "", username = "";
int error = 0;
while (reply.next()) {
switch (reply.fieldNumber()) {
case 1 -> keyPackage = reply.readBytes();
case 2 -> session = reply.readString();
case 5 -> error = (int) reply.readVarint();
case 8 -> username = reply.readString();
default -> reply.skip();
}
}
if (error != LOGIN_OK) throw loginError(error);
try {
if (keyPackage == null) throw new MyTsException("The account has no synchronisation key", 0);
byte[] itemKey;
try {
itemKey = MyTsCrypto.unwrapItemKey(accountKey, keyPackage);
} catch (GeneralSecurityException e) {
throw new MyTsException("Could not unlock the account's encrypted data", e);
}
return new Account(username, pull(session, itemKey));
} finally {
signOut(session);
}
}
private List<SyncItem> pull(String session, byte[] itemKey) throws IOException, MyTsException {
ProtobufWriter request = new ProtobufWriter().string(1, session);
for (int itemClass : ITEM_CLASSES) {
request.message(2, new ProtobufWriter().varint(1, itemClass).string(2, NO_VERSION));
}
request.string(3, NO_VERSION).varint(4, SYNC_VERSION_1_1);
ProtobufReader reply = new ProtobufReader(
transport.call("synchronization", "requestServerItems", request.toByteArray()));
List<byte[]> frames = new ArrayList<>();
int status = 0;
while (reply.next()) {
switch (reply.fieldNumber()) {
case 1 -> status = (int) reply.readVarint();
case 2 -> collectFrames(reply.readMessage(), frames);
default -> reply.skip();
}
}
if (status < IN_SYNC || status > NOT_IN_DB) {
throw new MyTsException("myTeamSpeak refused to synchronise (status " + status + ")", status);
}
List<SyncItem> items = new ArrayList<>();
for (byte[] frame : frames) {
SyncItem item;
try {
item = SyncItemDecoder.decode(MyTsCrypto.decryptItem(frame, itemKey));
} catch (GeneralSecurityException e) {
throw new MyTsException("Could not decrypt the account's items", e);
}
if (item != null) items.add(item);
}
return items;
}
/** The encrypted items of one {@code Sync_ItemClasses_Data}; tombstones carry none. */
private static void collectFrames(ProtobufReader itemClass, List<byte[]> frames) throws IOException {
while (itemClass.next()) {
if (itemClass.fieldNumber() != 3) {
itemClass.skip();
continue;
}
ProtobufReader detail = itemClass.readMessage();
byte[] blob = null;
boolean deleted = false;
while (detail.next()) {
switch (detail.fieldNumber()) {
case 3 -> blob = detail.readBytes();
case 4 -> deleted = detail.readBool();
default -> detail.skip();
}
}
if (!deleted && blob != null && blob.length > 0) frames.add(blob);
}
}
/** Ends the session rather than leave it for the server to expire; nothing to do if that fails. */
private void signOut(String session) {
if (session.isEmpty()) return;
try {
transport.call("authentication", "deleteSession", new ProtobufWriter().string(1, session).toByteArray());
} catch (IOException | MyTsException ignored) {
// The session expires on its own.
}
}
private static MyTsException loginError(int error) {
String message = switch (error) {
case MyTsException.LOGIN_FAILED -> "Wrong email address or password";
case LOGIN_EMAIL_PENDING -> "Confirm your email address before signing in";
case LOGIN_OFFLINE -> "myTeamSpeak is offline, try again later";
default -> "myTeamSpeak refused the sign-in (error " + error + ")";
};
return new MyTsException(message, error);
}
}

View File

@@ -0,0 +1,134 @@
package com.ts3client.myts;
import com.ts3client.config.AppDirs;
import com.ts3client.config.ProfileFiles;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.Properties;
/**
* The myTeamSpeak account this client stays signed in to. Signing in keeps the
* {@link MyTeamSpeak.Credentials} in the profile, private to the user, so the account
* can be read again later without asking for the password; signing out forgets them.
* Each read signs in afresh, so there is no server session to keep alive.
*/
public final class MyTeamSpeakLogin {
private static final String FILE_NAME = "myteamspeak.properties";
private final File file;
private final MyTeamSpeak service;
private MyTeamSpeak.Credentials credentials;
private String username = "";
MyTeamSpeakLogin(File file, MyTeamSpeak service) {
this.file = file;
this.service = service;
read();
}
public static MyTeamSpeakLogin load() {
return new MyTeamSpeakLogin(AppDirs.file(FILE_NAME), new MyTeamSpeak());
}
public synchronized boolean isSignedIn() {
return credentials != null;
}
/** Empty when signed out. */
public synchronized String email() {
return credentials == null ? "" : credentials.email();
}
/** The account's display name; the email when the account has none. */
public synchronized String username() {
return username.isBlank() ? email() : username;
}
/**
* Signs in and stays signed in, returning what the account holds. Blocks for the key
* derivation and a few requests. Nothing is kept when it fails.
*/
public MyTeamSpeak.Account signIn(String email, String password) throws IOException, MyTsException {
MyTeamSpeak.Credentials fresh = MyTeamSpeak.Credentials.derive(email.trim(), password);
MyTeamSpeak.Account account = service.download(fresh);
synchronized (this) {
credentials = fresh;
username = account.username();
write();
}
return account;
}
/**
* Reads the account again. When the server no longer takes the kept credentials —
* the password was changed elsewhere — this signs out before rethrowing.
*
* @throws IllegalStateException when signed out
*/
public MyTeamSpeak.Account fetch() throws IOException, MyTsException {
MyTeamSpeak.Credentials current;
synchronized (this) {
if (credentials == null) throw new IllegalStateException("Not signed in to myTeamSpeak");
current = credentials;
}
try {
MyTeamSpeak.Account account = service.download(current);
synchronized (this) {
if (credentials == current && !account.username().equals(username)) {
username = account.username();
write();
}
}
return account;
} catch (MyTsException e) {
if (!e.credentialsRejected()) throw e;
synchronized (this) {
if (credentials == current) signOut();
}
throw new MyTsException("myTeamSpeak no longer accepts the saved sign-in; the password may have "
+ "changed. Sign in again.", e.code());
}
}
public synchronized void signOut() {
credentials = null;
username = "";
//noinspection ResultOfMethodCallIgnored
file.delete();
}
private void read() {
if (!file.isFile()) return;
Properties p = new Properties();
try (InputStream in = new FileInputStream(file)) {
p.load(in);
String email = p.getProperty("email", "");
String token = p.getProperty("loginToken", "");
byte[] key = Base64.getDecoder().decode(p.getProperty("accountKey", ""));
if (email.isEmpty() || token.isEmpty() || key.length == 0) return;
credentials = new MyTeamSpeak.Credentials(email, token, key);
username = p.getProperty("username", "");
} catch (IOException | IllegalArgumentException unreadable) {
// Treated as signed out.
}
}
private void write() {
Properties p = new Properties();
p.setProperty("email", credentials.email());
p.setProperty("username", username);
p.setProperty("loginToken", credentials.loginToken());
p.setProperty("accountKey", Base64.getEncoder().encodeToString(credentials.accountKey()));
try {
AppDirs.createProfile();
ProfileFiles.write(file, out -> p.store(out, "myTeamSpeak sign-in: a derived token and key, not the password"));
} catch (IOException e) {
// Signed in for this run only.
}
}
}

View File

@@ -0,0 +1,136 @@
package com.ts3client.myts;
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Base64;
/**
* The end-to-end encryption of myTeamSpeak synchronisation, as the official
* client's {@code teamcrypto} does it (see {@code docs/myteamspeak/CRYPTO_RE_SALT.md}).
* Both keys come from the password: the login token proves it to the server, and
* the account key unwraps the item key the server hands back, which the server
* itself never learns.
*/
final class MyTsCrypto {
private static final int PBKDF2_ITERATIONS = 10_000;
private static final int LOGIN_TOKEN_BYTES = 48;
private static final int KEY_BYTES = 32;
private static final int GCM_TAG_BYTES = 16;
private static final int GCM_IV_BYTES = 12;
private static final int KEY_PACKAGE_VERSION = 2;
private static final int CTR_BLOCK_BYTES = 16;
private static final int ITEM_HASH_BYTES = 64;
private MyTsCrypto() {
}
/** What {@code LoginData.password} carries instead of the password. */
static String loginToken(String email, String password) throws GeneralSecurityException {
return Base64.getEncoder().encodeToString(derive(email, password, "ts3Login", LOGIN_TOKEN_BYTES));
}
static byte[] accountKey(String email, String password) throws GeneralSecurityException {
return derive(email, password, "ts3Encryption", KEY_BYTES);
}
/**
* Opens {@code LoginSession.key}: {@code 02 || tag[16] || iv[12] || ciphertext[32]},
* AES-256-GCM under the account key, no AAD.
*/
static byte[] unwrapItemKey(byte[] accountKey, byte[] keyPackage) throws GeneralSecurityException {
if (keyPackage.length != 1 + GCM_TAG_BYTES + GCM_IV_BYTES + KEY_BYTES || keyPackage[0] != KEY_PACKAGE_VERSION) {
throw new GeneralSecurityException("Unsupported account key package");
}
int ivAt = 1 + GCM_TAG_BYTES;
int dataAt = ivAt + GCM_IV_BYTES;
// JCA wants the tag after the ciphertext.
byte[] sealed = new byte[KEY_BYTES + GCM_TAG_BYTES];
System.arraycopy(keyPackage, dataAt, sealed, 0, KEY_BYTES);
System.arraycopy(keyPackage, 1, sealed, KEY_BYTES, GCM_TAG_BYTES);
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
gcm.init(Cipher.DECRYPT_MODE, new SecretKeySpec(accountKey, "AES"),
new GCMParameterSpec(GCM_TAG_BYTES * 8, keyPackage, ivAt, GCM_IV_BYTES));
try {
return gcm.doFinal(sealed);
} catch (AEADBadTagException e) {
throw new GeneralSecurityException("The account key does not match the password", e);
}
}
/**
* Decrypts an item frame, {@code iv[16] || ciphertext || SHA-512(plaintext)}, into
* its {@code Item_Data} message. The cipher is AES-256-CTR, but with the counter
* incremented little-endian, so JCA's own CTR mode cannot be used.
*/
static byte[] decryptItem(byte[] frame, byte[] itemKey) throws GeneralSecurityException {
int textLength = frame.length - CTR_BLOCK_BYTES - ITEM_HASH_BYTES;
if (textLength <= 0) throw new GeneralSecurityException("Truncated item");
Cipher aes = Cipher.getInstance("AES/ECB/NoPadding");
aes.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(itemKey, "AES"));
byte[] counter = Arrays.copyOf(frame, CTR_BLOCK_BYTES);
byte[] plain = new byte[textLength];
for (int off = 0; off < textLength; off += CTR_BLOCK_BYTES) {
byte[] keyStream = aes.doFinal(counter);
int n = Math.min(CTR_BLOCK_BYTES, textLength - off);
for (int i = 0; i < n; i++) plain[off + i] = (byte) (frame[CTR_BLOCK_BYTES + off + i] ^ keyStream[i]);
incrementLittleEndian(counter);
}
byte[] hash = MessageDigest.getInstance("SHA-512").digest(plain);
byte[] expected = Arrays.copyOfRange(frame, frame.length - ITEM_HASH_BYTES, frame.length);
if (!MessageDigest.isEqual(hash, expected)) throw new GeneralSecurityException("Item integrity check failed");
return plain;
}
/** PBKDF2-HMAC-SHA512 keyed by the password, salted with {@code lower(email) + purpose + password}. */
private static byte[] derive(String email, String password, String purpose, int length)
throws GeneralSecurityException {
byte[] salt = (asciiLowerCase(email) + purpose + password).getBytes(StandardCharsets.UTF_8);
Mac hmac = Mac.getInstance("HmacSHA512");
hmac.init(new SecretKeySpec(password.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
byte[] out = new byte[length];
byte[] blockSalt = Arrays.copyOf(salt, salt.length + 4);
for (int block = 1, off = 0; off < length; block++) {
blockSalt[salt.length] = (byte) (block >>> 24);
blockSalt[salt.length + 1] = (byte) (block >>> 16);
blockSalt[salt.length + 2] = (byte) (block >>> 8);
blockSalt[salt.length + 3] = (byte) block;
byte[] u = hmac.doFinal(blockSalt);
byte[] t = u.clone();
for (int i = 1; i < PBKDF2_ITERATIONS; i++) {
u = hmac.doFinal(u);
for (int j = 0; j < t.length; j++) t[j] ^= u[j];
}
int n = Math.min(t.length, length - off);
System.arraycopy(t, 0, out, off, n);
off += n;
}
return out;
}
/** The official client lowercases bytewise, leaving anything outside ASCII alone. */
private static String asciiLowerCase(String s) {
char[] chars = s.toCharArray();
for (int i = 0; i < chars.length; i++) {
if (chars[i] >= 'A' && chars[i] <= 'Z') chars[i] += 'a' - 'A';
}
return new String(chars);
}
private static void incrementLittleEndian(byte[] counter) {
for (int i = 0; i < counter.length && ++counter[i] == 0; i++) {
// carry into the next byte
}
}
}

View File

@@ -0,0 +1,30 @@
package com.ts3client.myts;
/** The myTeamSpeak service refused a request; the message is fit to show the user. */
public final class MyTsException extends Exception {
/** {@code ERROR_LOGIN_FAILED}: wrong email or password. */
static final int LOGIN_FAILED = 202;
private final int code;
MyTsException(String message, int code) {
super(message);
this.code = code;
}
MyTsException(String message, Throwable cause) {
super(message, cause);
this.code = 0;
}
/** The service's {@code ErrorCommon}/{@code SyncStatus} code or HTTP status; 0 when there is none. */
public int code() {
return code;
}
/** The server no longer accepts the email and password, for instance after a password change. */
public boolean credentialsRejected() {
return code == LOGIN_FAILED;
}
}

View File

@@ -0,0 +1,54 @@
package com.ts3client.myts;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
/**
* One call to the myTeamSpeak client API: a protobuf request to a method of a
* service endpoint, answered with a protobuf reply.
*/
interface MyTsTransport {
byte[] call(String endpoint, String method, byte[] request) throws IOException, MyTsException;
/**
* The official client's wire form: an HTTP/1.1 POST of {@code <method length><method><protobuf>}
* as {@code application/ts3cloud}. The reply is the bare protobuf, whatever its
* Content-Type says. Cloudflare in front turns away clients that look different,
* hence the cpp-httplib user agent.
*/
static MyTsTransport https(URI base) {
return (endpoint, method, request) -> {
byte[] name = method.getBytes(StandardCharsets.US_ASCII);
HttpURLConnection http = (HttpURLConnection) base.resolve(endpoint).toURL().openConnection();
try {
http.setRequestMethod("POST");
http.setConnectTimeout(15_000);
http.setReadTimeout(30_000);
http.setDoOutput(true);
http.setRequestProperty("Content-Type", "application/ts3cloud");
http.setRequestProperty("Accept", "*/*");
http.setRequestProperty("User-Agent", "cpp-httplib/0.11.1");
http.setFixedLengthStreamingMode(1 + name.length + request.length);
try (OutputStream out = http.getOutputStream()) {
out.write(name.length);
out.write(name);
out.write(request);
}
int status = http.getResponseCode();
if (status != HttpURLConnection.HTTP_OK) {
throw new MyTsException("The myTeamSpeak server answered HTTP " + status, status);
}
try (InputStream in = http.getInputStream()) {
return in.readAllBytes();
}
} finally {
http.disconnect();
}
};
}
}

View File

@@ -1,4 +1,4 @@
package com.ts3client.teamspeak;
package com.ts3client.proto;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -9,7 +9,7 @@ import java.nio.charset.StandardCharsets;
* and pull the value with the accessor matching the field's declared type;
* anything else is skipped with {@link #skip()}.
*/
final class ProtobufReader {
public final class ProtobufReader {
private static final int VARINT = 0;
private static final int FIXED64 = 1;
@@ -22,7 +22,7 @@ final class ProtobufReader {
private int fieldNumber;
private int wireType;
ProtobufReader(byte[] data) {
public ProtobufReader(byte[] data) {
this(data, 0, data.length);
}
@@ -33,7 +33,7 @@ final class ProtobufReader {
}
/** Advances to the next field; {@code false} at the end of the message. */
boolean next() throws IOException {
public boolean next() throws IOException {
if (pos >= end) return false;
long tag = readVarint();
fieldNumber = (int) (tag >>> 3);
@@ -42,11 +42,11 @@ final class ProtobufReader {
return true;
}
int fieldNumber() {
public int fieldNumber() {
return fieldNumber;
}
long readVarint() throws IOException {
public long readVarint() throws IOException {
long result = 0;
for (int shift = 0; shift < 64; shift += 7) {
if (pos >= end) throw new IOException("Truncated protobuf varint");
@@ -57,18 +57,18 @@ final class ProtobufReader {
throw new IOException("Malformed protobuf varint");
}
boolean readBool() throws IOException {
public boolean readBool() throws IOException {
return readVarint() != 0;
}
String readString() throws IOException {
public String readString() throws IOException {
int length = readLength();
String s = new String(data, pos, length, StandardCharsets.UTF_8);
pos += length;
return s;
}
byte[] readBytes() throws IOException {
public byte[] readBytes() throws IOException {
int length = readLength();
byte[] out = new byte[length];
System.arraycopy(data, pos, out, 0, length);
@@ -77,14 +77,14 @@ final class ProtobufReader {
}
/** A reader positioned over an embedded message; this reader moves past it. */
ProtobufReader readMessage() throws IOException {
public ProtobufReader readMessage() throws IOException {
int length = readLength();
ProtobufReader nested = new ProtobufReader(data, pos, pos + length);
pos += length;
return nested;
}
void skip() throws IOException {
public void skip() throws IOException {
switch (wireType) {
case VARINT -> readVarint();
case FIXED64 -> pos += 8;

View File

@@ -0,0 +1,47 @@
package com.ts3client.proto;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/** Builds a protocol-buffers message field by field; the counterpart of {@link ProtobufReader}. */
public final class ProtobufWriter {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
public ProtobufWriter varint(int field, long value) {
tag(field, 0);
rawVarint(value);
return this;
}
public ProtobufWriter string(int field, String value) {
return bytes(field, value.getBytes(StandardCharsets.UTF_8));
}
public ProtobufWriter bytes(int field, byte[] value) {
tag(field, 2);
rawVarint(value.length);
out.write(value, 0, value.length);
return this;
}
public ProtobufWriter message(int field, ProtobufWriter message) {
return bytes(field, message.toByteArray());
}
public byte[] toByteArray() {
return out.toByteArray();
}
private void tag(int field, int wireType) {
rawVarint(((long) field << 3) | wireType);
}
private void rawVarint(long value) {
while ((value & ~0x7FL) != 0) {
out.write((int) (value & 0x7F) | 0x80);
value >>>= 7;
}
out.write((int) value);
}
}

View File

@@ -26,7 +26,8 @@ public sealed interface SyncItem {
}
/**
* @param identityUuid {@link Identity#uuid()} of the identity to connect with; empty for the default
* @param identityUuid {@link Identity#uuid()} of the identity to connect with (or its {@link Identity#name()}
* in some myTeamSpeak items); empty for the default
* @param defaultChannel channel path to join, "/"-separated; empty for the server default
* @param defaultChannelId the same channel by id, {@code 0} when unknown
*/

View File

@@ -1,12 +1,15 @@
package com.ts3client.teamspeak;
import com.ts3client.proto.ProtobufReader;
import java.io.IOException;
/**
* Decodes a serialised {@code com.teamspeak.sync.proto.Item_Data} message (the
* schema is embedded in the TeamSpeak client binary) into a {@link SyncItem}.
* schema is embedded in the TeamSpeak client binary) into a {@link SyncItem}:
* a row of the local store, or a decrypted myTeamSpeak item, which is the same.
*/
final class SyncItemDecoder {
public final class SyncItemDecoder {
/** Item_Data.parent of a top-level item. */
private static final String ROOT_PARENT = "ffffffff-ffff-ffff-ffff-ffffffffffff";
@@ -20,7 +23,7 @@ final class SyncItemDecoder {
* @return the item, or {@code null} when it is of an unmodelled class or was
* deleted locally and only lingers until the deletion is synced
*/
static SyncItem decode(byte[] blob) throws IOException {
public static SyncItem decode(byte[] blob) throws IOException {
String uuid = "";
String parent = "";
boolean deleted = false;

View File

@@ -10,16 +10,18 @@ import com.github.manevolent.ts3j.identity.LocalIdentity;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Carries the TeamSpeak 3 client's synchronised bookmarks and identities over
* into this client's stores. Importing is additive and idempotent: an identity
* Carries the TeamSpeak 3 client's synchronised bookmarks and identities —
* from its local store or a myTeamSpeak account — over into this client's stores. Importing is additive and idempotent: an identity
* already present (same unique ID) or a bookmark already present (same label,
* address and port) is left alone, and bookmarks are wired to the identities
* they referenced in TeamSpeak.
@@ -59,7 +61,38 @@ public final class TeamSpeakImporter {
/** Imports every identity and bookmark. */
public Result importAll(File db) throws IOException {
return importItems(TeamSpeakSettingsDb.readSyncItems(db), true, false);
return importAll(TeamSpeakSettingsDb.readSyncItems(db));
}
/** Imports every identity and bookmark among items read elsewhere, such as from a myTeamSpeak account. */
public Result importAll(List<SyncItem> items) throws IOException {
return importItems(items, true, false);
}
/**
* Imports the chosen items out of {@code all}, together with the identities the chosen
* bookmarks connect with, which only {@code all} may hold.
*/
public Result importSelected(List<SyncItem> all, Collection<? extends SyncItem> chosen) throws IOException {
Map<String, SyncItem.Identity> syncIdentities = identitiesOf(all);
Set<SyncItem> selection = new LinkedHashSet<>(chosen);
for (SyncItem item : chosen) {
if (!(item instanceof SyncItem.Bookmark bookmark)) continue;
SyncItem.Identity identity = identityOf(bookmark, syncIdentities);
if (identity != null) selection.add(identity);
}
return importItems(new ArrayList<>(selection), true, false);
}
/** Whether the item is here already: an identity with the same key, or a bookmark with the same label and address. */
public boolean isPresent(SyncItem item) {
if (item instanceof SyncItem.Bookmark bookmark) return find(bookmark) != null;
if (!(item instanceof SyncItem.Identity identity)) return false;
try {
return identities.byUniqueId(IdentityStore.parseIdentityString(identity.identity()).getUid().toBase64()) != null;
} catch (IOException malformed) {
return false;
}
}
/**
@@ -81,24 +114,23 @@ public final class TeamSpeakImporter {
private Result importItems(List<SyncItem> items, boolean withBookmarks, boolean onlyUsedIdentities)
throws IOException {
Map<String, SyncItem.Identity> syncIdentities = new LinkedHashMap<>();
Map<String, SyncItem.Identity> syncIdentities = identitiesOf(items);
List<SyncItem.Bookmark> syncBookmarks = new ArrayList<>();
for (SyncItem item : items) {
if (item instanceof SyncItem.Identity identity) syncIdentities.put(identity.uuid(), identity);
if (item instanceof SyncItem.Bookmark bookmark && !bookmark.address().isBlank()) syncBookmarks.add(bookmark);
}
if (onlyUsedIdentities) {
// Only the identities some bookmark connects with come along.
Set<String> referenced = new HashSet<>();
for (SyncItem.Bookmark bookmark : syncBookmarks) referenced.add(bookmark.identityUuid());
boolean defaultUsed = referenced.contains("");
syncIdentities.values().removeIf(identity ->
!referenced.contains(identity.uuid()) && !(defaultUsed && identity.isDefault()));
Set<SyncItem.Identity> referenced = new HashSet<>();
for (SyncItem.Bookmark bookmark : syncBookmarks) {
SyncItem.Identity identity = identityOf(bookmark, syncIdentities);
if (identity != null) referenced.add(identity);
}
syncIdentities.values().retainAll(referenced);
}
int identitiesAdded = 0, identitiesKnown = 0, bookmarksAdded = 0, bookmarksKnown = 0;
Map<String, IdentityEntry> imported = new HashMap<>();
SyncItem.Identity syncDefault = null;
boolean settingsChanged = false;
for (SyncItem.Identity identity : syncIdentities.values()) {
@@ -117,7 +149,6 @@ public final class TeamSpeakImporter {
}
imported.put(identity.uuid(), entry);
if (!identity.isDefault()) continue;
syncDefault = identity;
// No default of our own yet: TeamSpeak's default identity and nickname become ours.
if (identities.byId(settings.defaultIdentityId) == null) {
settings.defaultIdentityId = entry.getId();
@@ -134,9 +165,10 @@ public final class TeamSpeakImporter {
continue;
}
// A bookmark naming no identity connects with the default one, as it does here.
SyncItem.Identity syncIdentity = bookmark.identityUuid().isEmpty()
? syncDefault : syncIdentities.get(bookmark.identityUuid());
bookmarks.add(toBookmark(bookmark, syncIdentity, imported.get(bookmark.identityUuid())));
SyncItem.Identity syncIdentity = identityOf(bookmark, syncIdentities);
IdentityEntry identity = bookmark.identityUuid().isEmpty() || syncIdentity == null
? null : imported.get(syncIdentity.uuid());
bookmarks.add(toBookmark(bookmark, syncIdentity, identity));
bookmarksAdded++;
}
if (bookmarksAdded > 0) bookmarks.save();
@@ -157,6 +189,29 @@ public final class TeamSpeakImporter {
return b;
}
private static Map<String, SyncItem.Identity> identitiesOf(List<SyncItem> items) {
Map<String, SyncItem.Identity> identities = new LinkedHashMap<>();
for (SyncItem item : items) {
if (item instanceof SyncItem.Identity identity) identities.put(identity.uuid(), identity);
}
return identities;
}
/**
* The identity a bookmark connects with. Bookmarks normally name it by item UUID,
* but some — seen in a myTeamSpeak account — name it by its display name instead.
*/
private static SyncItem.Identity identityOf(SyncItem.Bookmark bookmark, Map<String, SyncItem.Identity> identities) {
String ref = bookmark.identityUuid();
for (SyncItem.Identity identity : identities.values()) {
if (ref.isEmpty() ? identity.isDefault() : identity.uuid().equals(ref)) return identity;
}
for (SyncItem.Identity identity : identities.values()) {
if (identity.name().equals(ref)) return identity;
}
return null;
}
private Bookmark find(SyncItem.Bookmark sync) {
for (Bookmark b : bookmarks.all()) {
if (b.port == sync.port()

View File

@@ -0,0 +1,65 @@
package com.ts3client.myts;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MyTeamSpeakLoginTest {
@Test
void staysSignedInWithoutKeepingThePassword(@TempDir File dir) throws Exception {
File file = new File(dir, "myteamspeak.properties");
MyTeamSpeakTest.ReplayServer server = new MyTeamSpeakTest.ReplayServer();
MyTeamSpeakLogin login = new MyTeamSpeakLogin(file, new MyTeamSpeak(server));
assertFalse(login.isSignedIn());
login.signIn(MyTsCryptoTest.EMAIL, MyTsCryptoTest.PASSWORD);
assertTrue(login.isSignedIn());
assertEquals("tester", login.username());
assertFalse(Files.readString(file.toPath(), StandardCharsets.ISO_8859_1).contains(MyTsCryptoTest.PASSWORD));
MyTeamSpeakLogin restarted = new MyTeamSpeakLogin(file, new MyTeamSpeak(server));
assertTrue(restarted.isSignedIn());
assertEquals(MyTsCryptoTest.EMAIL, restarted.email());
assertEquals("tester", restarted.username());
assertEquals(1, restarted.fetch().bookmarks());
restarted.signOut();
assertFalse(file.exists());
assertFalse(restarted.isSignedIn());
}
@Test
void aFailedSignInKeepsNothing(@TempDir File dir) {
File file = new File(dir, "myteamspeak.properties");
MyTeamSpeakTest.ReplayServer server = new MyTeamSpeakTest.ReplayServer();
server.loginError = MyTsException.LOGIN_FAILED;
MyTeamSpeakLogin login = new MyTeamSpeakLogin(file, new MyTeamSpeak(server));
assertThrows(MyTsException.class, () -> login.signIn(MyTsCryptoTest.EMAIL, "wrong"));
assertFalse(login.isSignedIn());
assertFalse(file.exists());
}
@Test
void signsOutWhenThePasswordChangedElsewhere(@TempDir File dir) throws Exception {
File file = new File(dir, "myteamspeak.properties");
MyTeamSpeakTest.ReplayServer server = new MyTeamSpeakTest.ReplayServer();
MyTeamSpeakLogin login = new MyTeamSpeakLogin(file, new MyTeamSpeak(server));
login.signIn(MyTsCryptoTest.EMAIL, MyTsCryptoTest.PASSWORD);
server.loginError = MyTsException.LOGIN_FAILED;
MyTsException e = assertThrows(MyTsException.class, login::fetch);
assertTrue(e.credentialsRejected());
assertFalse(login.isSignedIn());
assertFalse(file.exists());
}
}

View File

@@ -0,0 +1,112 @@
package com.ts3client.myts;
import com.ts3client.proto.ProtobufReader;
import com.ts3client.proto.ProtobufWriter;
import com.ts3client.teamspeak.SyncItem;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MyTeamSpeakTest {
private static final String SESSION = "00000000-1111-2222-3333-444444444444";
static MyTeamSpeak.Credentials credentials(String password) throws MyTsException {
return MyTeamSpeak.Credentials.derive(MyTsCryptoTest.EMAIL, password);
}
/** Replays the captured account: answers each call and records it. */
static final class ReplayServer implements MyTsTransport {
final List<String> calls = new ArrayList<>();
int loginError = 200;
String loginToken;
@Override
public byte[] call(String endpoint, String method, byte[] request) throws IOException {
calls.add(endpoint + "/" + method);
return switch (method) {
case "login" -> login(request);
case "requestServerItems" -> items(request);
default -> new byte[0];
};
}
private byte[] login(byte[] request) throws IOException {
ProtobufReader r = new ProtobufReader(request);
while (r.next()) {
if (r.fieldNumber() == 2) loginToken = r.readString();
else r.skip();
}
if (loginError != 200) return new ProtobufWriter().varint(5, loginError).toByteArray();
return new ProtobufWriter()
.bytes(1, MyTsCryptoTest.KEY_PACKAGE)
.string(2, SESSION)
.varint(5, 200)
.string(8, "tester")
.toByteArray();
}
private byte[] items(byte[] request) throws IOException {
ProtobufReader r = new ProtobufReader(request);
while (r.next()) {
if (r.fieldNumber() == 1) assertEquals(SESSION, r.readString());
else r.skip();
}
try {
ProtobufWriter bookmark = new ProtobufWriter()
.string(1, "43c5d058-4803-3cd8-b844-15fdcc92e730")
.string(2, "5d831e3c-d5aa-0e66-7af2-a0feb13af048")
.bytes(3, MyTsCryptoTest.itemFrame());
ProtobufWriter tombstone = new ProtobufWriter().string(1, "gone").varint(4, 1);
return new ProtobufWriter()
.varint(1, 301)
.message(2, new ProtobufWriter().varint(1, 0).string(2, "v").message(3, bookmark).message(3, tombstone))
.message(2, new ProtobufWriter().varint(1, 1).varint(4, 1))
.string(3, "g")
.toByteArray();
} catch (java.security.GeneralSecurityException e) {
throw new AssertionError(e);
}
}
}
@Test
void downloadsAndDecryptsTheAccount() throws Exception {
ReplayServer server = new ReplayServer();
MyTeamSpeak.Account account = new MyTeamSpeak(server).download(credentials(MyTsCryptoTest.PASSWORD));
assertEquals("tester", account.username());
assertEquals(1, account.items().size());
SyncItem.Bookmark bookmark = assertInstanceOf(SyncItem.Bookmark.class, account.items().get(0));
assertEquals("server.lixko.eu", bookmark.address());
assertEquals(9987, bookmark.port());
assertEquals("niger", bookmark.nickname());
assertEquals(MyTsCryptoTest.LOGIN_TOKEN, server.loginToken);
assertEquals(List.of("authentication/login", "synchronization/requestServerItems",
"authentication/deleteSession"), server.calls);
}
@Test
void reportsAWrongPassword() {
ReplayServer server = new ReplayServer();
server.loginError = 202;
MyTsException e = assertThrows(MyTsException.class,
() -> new MyTeamSpeak(server).download(credentials("wrong")));
assertEquals(202, e.code());
assertEquals(List.of("authentication/login"), server.calls);
}
@Test
void signsOutWhenTheKeyDoesNotOpen() {
// The server accepted the login token but the key does not open with this password.
ReplayServer server = new ReplayServer();
assertThrows(MyTsException.class, () -> new MyTeamSpeak(server).download(credentials("other")));
assertEquals("authentication/deleteSession", server.calls.get(server.calls.size() - 1));
}
}

View File

@@ -0,0 +1,117 @@
package com.ts3client.myts;
import org.junit.jupiter.api.Test;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Vectors from the official client (see CRYPTO_RE_SALT.md). The sign-in sample is a login the client
* computed for an address with no account; the item is a real captured one with its item key.
*/
class MyTsCryptoTest {
static final String EMAIL = "probe.nobody@example.com";
static final String PASSWORD = "wrongpassword1";
static final String LOGIN_TOKEN = "kJLau9cQuYPQi/hi0ey4dPBChwvTMhz2lfAYHWQnNNtaVffCzHHac8mIFVb2FpN4";
static final byte[] ITEM_KEY = hex("22efa5d631ddaa11ec97ccb82846b4d24598a9376319f444b5065cd47b391b2d");
/** {@code LoginSession.key} sealing {@link #ITEM_KEY} for {@link #EMAIL}, laid out as the server sends it. */
static final byte[] KEY_PACKAGE = keyPackage();
/** A bookmark: server.lixko.eu:9987, nickname "niger". */
static final byte[] ITEM_DATA = hex("122434336335643035382d343830332d336364382d623834342d3135666463633932653733301a24"
+ "64373663663736312d316163332d373434662d366234312d36363633386362643165303320003000"
+ "3a00420048f7f5dbd506820189010a19457269c48d516f76205465616d537065616b207365727665"
+ "72120f7365727665722e6c69786b6f2e657518834e22056e696765722a00320744656661756c743a"
+ "0042004a0050005a0744656661756c74620744656661756c746a0070007a1c2b547967324a747845"
+ "3876524e5a702b4a6955426e6d4268304d593d8a0100900100980100b00101");
/** The capture cut the frame 37 bytes into its SHA-512 trailer, so the test completes it. */
static byte[] itemFrame() throws GeneralSecurityException {
byte[] captured = hex("1114428e2059c038f339b1c1a6bc4eb0eac1f4479ec2764ea53c0b4dd0fc4626"
+ "4642cab8908d295933fac43152154f58990909e933d899b86d4776ae8c52f61575b33932d4667fc3f"
+ "b50e78a2a056dc6c8cd74e3e2446275e0d351c7139bc686a3555b4dd3dd6fdfae1c7176e84f99b3"
+ "0df52dbe9b95e28d9545e2dd3188fec7e34ac233fb7de6d21db8795a45186ff91a01d23189d072d6"
+ "afb60cad9b1c834e35acf04006daa1d6e439750afaa00a4c63e53f10cf54d2820108c1b859b394d0"
+ "ba7d80b38de402b631da9ba4daedbff4d9de85cdeae7466b516ac5abe9af4d43e9df1c9bf8951d1"
+ "2e9f5c42ce7cf802443d4bc051902164ec510126130dd962e54778518ae0a44947e6dd19975eef8b"
+ "33ce8");
byte[] hash = MessageDigest.getInstance("SHA-512").digest(ITEM_DATA);
byte[] frame = Arrays.copyOf(captured, 16 + ITEM_DATA.length + hash.length);
System.arraycopy(hash, 0, frame, 16 + ITEM_DATA.length, hash.length);
return frame;
}
@Test
void derivesTheOfficialLoginToken() throws GeneralSecurityException {
assertEquals(LOGIN_TOKEN, MyTsCrypto.loginToken(EMAIL, PASSWORD));
}
@Test
void emailCaseDoesNotMatter() throws GeneralSecurityException {
assertEquals(LOGIN_TOKEN, MyTsCrypto.loginToken("Probe.Nobody@EXAMPLE.com", PASSWORD));
}
/** The same construction opened a real account's key; this pins it for the sample address. */
@Test
void derivesTheAccountKey() throws GeneralSecurityException {
assertArrayEquals(hex("f0e2ba6d60d69c8eb6bb9e56f0093490db7379b3f4df5475599ddad2a750d00d"),
MyTsCrypto.accountKey(EMAIL, PASSWORD));
}
@Test
void unwrapsTheItemKey() throws GeneralSecurityException {
assertArrayEquals(ITEM_KEY, MyTsCrypto.unwrapItemKey(MyTsCrypto.accountKey(EMAIL, PASSWORD), KEY_PACKAGE));
}
@Test
void wrongPasswordCannotUnwrap() throws GeneralSecurityException {
byte[] accountKey = MyTsCrypto.accountKey(EMAIL, "not the password");
assertThrows(GeneralSecurityException.class, () -> MyTsCrypto.unwrapItemKey(accountKey, KEY_PACKAGE));
}
@Test
void decryptsAnItem() throws GeneralSecurityException {
assertArrayEquals(ITEM_DATA, MyTsCrypto.decryptItem(itemFrame(), ITEM_KEY));
}
@Test
void rejectsATamperedItem() throws GeneralSecurityException {
byte[] frame = itemFrame();
frame[40] ^= 1;
assertThrows(GeneralSecurityException.class, () -> MyTsCrypto.decryptItem(frame, ITEM_KEY));
}
/** {@code 02 || tag || iv || ciphertext}; JCA puts the tag last, the server first. */
private static byte[] keyPackage() {
try {
byte[] iv = hex("0102030405060708090a0b0c");
Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
gcm.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(MyTsCrypto.accountKey(EMAIL, PASSWORD), "AES"),
new GCMParameterSpec(128, iv));
byte[] sealed = gcm.doFinal(ITEM_KEY);
byte[] out = new byte[1 + 16 + 12 + 32];
out[0] = 2;
System.arraycopy(sealed, 32, out, 1, 16);
System.arraycopy(iv, 0, out, 17, 12);
System.arraycopy(sealed, 0, out, 29, 32);
return out;
} catch (GeneralSecurityException e) {
throw new AssertionError(e);
}
}
static byte[] hex(String s) {
return HexFormat.of().parseHex(s);
}
}

View File

@@ -15,6 +15,7 @@ import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -75,6 +76,40 @@ class TeamSpeakImporterTest {
assertEquals(1, identities.all().size());
}
@Test
void resolvesABookmarkNamingItsIdentity() throws Exception {
Settings settings = new Settings();
IdentityStore identities = IdentityStore.load(settings);
Bookmarks bookmarks = new Bookmarks();
SyncItem.Identity identity = TeamSpeakSettingsDb.readSyncItems(SyncItemsTest.fixture()).stream()
.filter(SyncItem.Identity.class::isInstance).map(SyncItem.Identity.class::cast).findFirst().orElseThrow();
SyncItem.Bookmark bookmark = new SyncItem.Bookmark("b", "", "Server", "example.com", 9987, "", "",
identity.name(), "", "", "", 0, false);
new TeamSpeakImporter(identities, bookmarks, settings).importAll(List.of(bookmark, identity));
assertEquals(identities.all().get(0).getId(), bookmarks.all().get(0).identityId);
assertEquals(identity.nickname(), bookmarks.all().get(0).nickname);
}
@Test
void importsTheSelectionWithTheIdentitiesItNeeds() throws Exception {
Settings settings = new Settings();
IdentityStore identities = IdentityStore.load(settings);
Bookmarks bookmarks = new Bookmarks();
TeamSpeakImporter importer = new TeamSpeakImporter(identities, bookmarks, settings);
List<SyncItem> all = TeamSpeakSettingsDb.readSyncItems(SyncItemsTest.fixture());
SyncItem.Bookmark lixko = all.stream().filter(SyncItem.Bookmark.class::isInstance)
.map(SyncItem.Bookmark.class::cast).filter(b -> b.name().equals("Lixko")).findFirst().orElseThrow();
assertFalse(importer.isPresent(lixko));
TeamSpeakImporter.Result result = importer.importSelected(all, List.of(lixko));
assertEquals(1, result.bookmarksAdded());
assertEquals(1, result.identitiesAdded(), "the identity the bookmark connects with");
assertEquals(identities.all().get(0).getId(), bookmarks.all().get(0).identityId);
assertTrue(importer.isPresent(lixko));
assertTrue(all.stream().filter(SyncItem.Identity.class::isInstance).allMatch(importer::isPresent));
}
@Test
void identityImportRecognisesAKnownKey() throws Exception {
Settings settings = new Settings();

View File

@@ -0,0 +1,68 @@
# myTeamSpeak crypto — reverse-engineering progress & resume notes
Status: **password-login and item crypto cracked (upload framing too); decryption implemented.** The item is
`IV[16] || little-endian AES-CTR ciphertext || SHA512(plaintext)`; the old fixture was a capture truncated
37 bytes into its digest. See `CRYPTO_RE_SALT.md` section 0 for formulas, addresses, captured outputs, and
the Java implementation. The notes below are retained as historical RE context.
## Confirmed facts
- TeamSpeak's `teamcrypto` provides ONLY: AES-256-GCM, AES-CTR, SHA-256, SHA-512, CTR-DRBG.
No PBKDF2/scrypt/Argon2/HKDF of its own (those strings in the binary are from statically-linked
OpenSSL, unused by the sync code). => everything is buildable from SHA-2 + AES-GCM, so BouncyCastle/JCA
can replicate it once the recipe is known. Nothing exotic to port.
- Sync is end-to-end encrypted. Local settings.db stores items DECRYPTED; only the wire blobs are encrypted.
- `cloud_sync_client/src/lib/Encryption.cpp` asserts `plain_hash.size() == teamcrypto::sha2::sha512_size`
(64) => a SHA-512 "plain_hash" is central. `Item_Manager.cpp` asserts `!salt.empty()` => items use a salt.
- **Login token is DETERMINISTIC** (verified: same email+password → identical 48-byte base64 token twice).
So it is a reproducible KDF, not randomized. 48 bytes out.
## Ground-truth samples (test account; its credentials are not committed)
Kept in the session scratchpad; re-capture via the harness if needed. Two login pairs
(email, password, 48-byte token) — determinism confirmed. One LoginSession with:
user_public_key 32B, encrypted_user_private_key 112B, my_teamspeak_id `01 20 <32B>`. One encrypted
bookmark item_blob (274B, leading `0x11`) whose DECRYPTED plaintext is known (from local settings.db):
`server.lixko.eu`, nick "niger", uid `+Tyg2JtxE8vRNZp+JiUBnmBh0MY=`. Recovery key = 32B.
## Ruled out (brute-forced against real samples)
- Login token: NOT plain SHA-256/384/512, HMAC-*, PBKDF2, scrypt, Argon2 over any obvious
email/password combo with common salts/peppers; NOT ~35 SHA-512 concat/HMAC/xor constructions tested
against BOTH samples. => baked-in salt/pepper or non-obvious structure; must be read from code.
- Item blob: NOT AES-256-GCM under {recovery key, sha256(rk), sha512(rk) halves} with nonce at
offset 0/1, len 12/16, tag-last, and AAD ∈ {none, item_uuid, item_version, both, binary UUIDs, 0x11}.
=> item key is a derivation of the account data key (which is wrapped by the password-derived key
and/or the recovery key), not the recovery key directly.
## Located in the binary (arm64 `re-android/.../libteamspeak_client.so`; file offset == vaddr in .rodata/.text)
- Encryption.cpp SHA-512 assert string: vaddr 0x1baed6; referenced by code at **0xa8c280**;
enclosing function starts at **0xa8c198** (stack 0x1e0). That function dispatches through a vtable
(`ldr x9,[x8,#0x18]`/`[x8,#0x48]`; `blr x9`) — the SHA-512/AES-GCM are behind an interface object.
- SHA-512 K-table @ vaddr **0x29f4d8**; SHA-256 K-table @ **0x29f318**; SHA-512 IV0 @ 0x241580.
- Desktop x86-64 `ts3client_linux_amd64`: same assert string @ vaddr **0x2dcac1** (.rodata).
- Login path entry: `Java_..._AccountManager_setupSyncAccount` @ arm64 0x8931a8 -> converts the 3
String args (email, password, device) -> calls Account_Manager_Impl vtable slot at `[vtable+0x18]`.
## Tools built (in session scratchpad)
- `disasm.py <lib> <vaddr> <len>` — capstone arm64 disassembler, annotates adrp+add string loads and
bl targets with .symtab/.dynsym names.
- `xref.py <lib> <string>` / `xrefaddr.py <lib> <vaddr> [window]` — find code adrp(+add/ldr) xrefs.
NOTE: capstone adrp op_str includes `#`; strip it when parsing (already fixed). ldr literal-pool
(`ldr xN, #imm` PC-relative) xrefs are NOT yet handled — add this to find mbedtls SHA callers.
## Next steps (pick one, both bounded)
1. **Static:** from the SHA-512 K-table (0x29f4d8) find the compression fn (add literal-pool ldr xref
handling), then its caller (sha512 one-shot wrapper), then THAT wrapper's callers — one is the login
KDF (expected linear: reads password + a constant salt -> SHA-512 -> 48-byte token), another is
Encryption. Read the login KDF to get the salt + truncation. Then read Encryption's item path
(key derivation + GCM nonce/tag/AAD layout). Verify each against the ground-truth samples.
2. **Dynamic (faster to interpret):** gdb the running desktop client (harness in headless-ui-testing
memory). Anchor: find the Encryption fn in the x86-64 binary via lea-xref to the assert string
@0x2dcac1, break there; trigger a login (client decrypts the bookmark) and read the AES key/nonce/
AAD + the SHA-512 inputs from registers/stack. ptrace needs `sudo gdb` (yama scope=1 here) or
ptrace_scope=0. libcrypto.so.1.1 EVP_PBE_scrypt/PKCS5_PBKDF2_HMAC are breakable but NOT used by the
login KDF (it's teamcrypto), so break on the located Encryption fn, not on OpenSSL.
## Capture harness (working)
DNS/connect LD_PRELOAD shim redirecting *.myteamspeak.com -> local TLS relay (client trusts an added CA
via SSL_CERT_FILE/SSL_CERT_DIR); relay must speak http/1.1 upstream. Drive the official client under
Xvfb+xdotool (licence: scroll to end then accept; dismiss the promo webview; fresh profile = clear
AccountData+ProtobufItems in settings.db). See [[headless-ui-testing]].

View File

@@ -0,0 +1,378 @@
# myTeamSpeak crypto — reverse-engineering results and reproducible vectors
This document is standalone. It contains every measured byte, the constructions recovered from the
official client, and the remaining uncertainty needed to finish a Java read-only cloud-sync client.
## 0. Results recovered on 2026-09-25/26
### Login token: solved and verified
The `LoginData.password` value is standard-base64 of:
```
PBKDF2-HMAC-SHA512(
password = UTF8(password),
salt = UTF8(asciiLower(email) + "ts3Login" + password),
iterations = 10000,
dkLen = 48
)
```
Only the email is lowercased. Both independent vectors in section 1a reproduce byte-for-byte. The
official implementation lowercases bytes; email addresses used by the protocol are effectively ASCII.
Static x86-64 Android evidence:
- `Account_Manager_Impl` RTTI string at `0x24a0a0`, typeinfo at `0x11c1088`, vtable address point at
`0x11c0dc0`; setup slot `+0x18` resolves to `0xa03e40`.
- KDF construction helper `0xab74a0` loads the literal `ts3Login` at `0x223384`.
- PBKDF helper `0xab94a0` fixes `dkLen=0x30`, `iterations=0x2710`, and SHA-512.
### Account/item key unwrap: solved and verified
The account wrapping key uses the parallel construction:
```
PBKDF2-HMAC-SHA512(
password = UTF8(password),
salt = UTF8(asciiLower(email) + "ts3Encryption" + password),
iterations = 10000,
dkLen = 32
)
```
For the captured account it yielded the key that opened that account's `LoginSession.key` below.
`LoginSession.key` is a versioned AES-256-GCM package:
```
byte 0 version = 0x02
bytes 1..16 authentication tag (16 bytes)
bytes 17..28 IV (12 bytes)
bytes 29.. ciphertext (32 bytes in the captured login)
AAD none
```
JCA expects `ciphertext || tag`, so the tag must be moved from the package prefix before calling
`AES/GCM/NoPadding`. Decrypting the captured account's package produced its 32-byte item/data key:
`22efa5d631ddaa11ec97ccb82846b4d24598a9376319f444b5065cd47b391b2d`.
The `ts3Encryption` literal is at x86-64 Android address `0x22aeca`. `Encryption_Impl_V2` has RTTI at
`0x259f5c`, typeinfo at `0x11ca088`, vtable address point at `0x11c9ff0`, and constructor `0xab9b20`.
Its fields establish key/tag/IV sizes of 32/16/12. Direct GCM decrypt at `0xaba140` slices the package
as tag, IV, ciphertext.
### Item framing and CTR transform: solved
The complete item frame is:
```
first 16 bytes initial 128-bit counter/IV
next N bytes AES-256-CTR ciphertext
last 64 bytes SHA-512 of the plaintext (not encrypted)
counter update increment the full 128-bit value in LITTLE-endian byte order
```
JCA's `AES/CTR/NoPadding` increments big-endian and therefore cannot be used directly. Generate each
keystream block using AES-ECB and increment counter byte 0 first. Applying this construction with the
unwrapped item key decrypts the captured blob's first 231 payload bytes into a valid `Item_Data`
bookmark matching the local database, except that the captured older revision has `manipulated=0`
instead of `3`. This proves the key and CTR construction independently of the local copy.
The supposed 27 mystery bytes were a truncated integrity suffix. They are exactly the first 27 bytes of
`SHA512(plaintext)`:
`4ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8`. The complete digest is:
`4ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8d1563dc7f316043eddfeba1c77710975b0cc3d4090b82943fdcb161d2ed953527073311fc5`.
Thus the recorded 274-byte fixture is the first 274 bytes of a 311-byte frame and is missing the final
37 digest bytes; it was the extraction/capture that was incomplete, not the wire format.
Static proof in high-level decrypt `0xab7c90`: it rejects inputs of 64 bytes or fewer, copies the final
64 bytes aside, passes the prefix to vtable slot `+0x58` (CTR decrypt), hashes the plaintext through
slot `+0x18`, and compares the two 64-byte strings. Encrypt `0xab77d0` performs the inverse: SHA-512
the plaintext, CTR-encrypt through `+0x48`, then append the digest.
Relevant V2 vtable entries: SHA-512 `+0x18` (`0xab9c60`), AES-GCM encrypt/decrypt `+0x38/+0x40`, CTR
encrypt/decrypt `+0x48/+0x58`, and the little-endian CTR helper at `0xabad00`.
### Java implementation
The verified constructions are implemented without an additional crypto dependency in
`core/src/main/java/com/ts3client/myts/MyTsCrypto.java` (decryption only, as the client only imports).
Captured-vector tests live in `core/src/test/java/com/ts3client/myts/MyTsCryptoTest.java`.
The original goals were:
1. `loginToken(email, password) -> 48-byte value` (base64'd into `LoginData.password`), and
2. `decryptItem(item_blob, ...) -> Item_Data protobuf plaintext`.
Both goals, including authenticated item framing, are complete.
All primitives are standard (AES-256-GCM, SHA-256, SHA-512, CTR-DRBG — TeamSpeak's static "teamcrypto"
lib has nothing else). The unknown is the **construction / salt**, not the primitives.
> The captured account's email, password, login token, keys and session are deliberately left out: the
> login token and account key sign in and decrypt just as the password does. Sample B below is a login the
> client computed for an address without an account.
---
## 1. Ground-truth samples (all hex unless noted)
### 1a. Login token (deterministic — verified identical across two separate logins for the same creds)
Wire framing of the request body to `POST https://clientapi.myteamspeak.com/authentication`:
`0x05 "login" <LoginData protobuf>`, where `LoginData { email=1:string, password=2:string }`, and the
`password` field is base64 text of exactly 48 bytes.
Sample A (server ACCEPTED this login): a real account; omitted. It reproduces byte-for-byte too.
Sample B (server REJECTED — wrong password — but the client still computed & sent the token, so it is a
valid input→output pair of the SAME client-side KDF):
- email = `probe.nobody@example.com`
- password = `wrongpassword1`
- token (base64) = `kJLau9cQuYPQi/hi0ey4dPBChwvTMhz2lfAYHWQnNNtaVffCzHHac8mIFVb2FpN4`
- token (48 bytes) = `9092dabbd710b983d08bf862d1ecb874f042870bd3321cf695f0181d642734db5a55f7c2cc71da73c9881556f6169378`
Determinism ⇒ no random salt/nonce in the token; any salt is fixed or derived from email/password.
### 1b. LoginSession reply for Sample A (values omitted)
`LoginSession { key=1 bytes, session=2 string, limits=3, uuid=4 string, error=5 varint,
purge=6 varint, username=8 string, myts_id_data=9 MyTeamSpeakIdData, ... alternative_login_info=16 }`
- error (f5) = 200 (ErrorCommon.ERROR_LOGIN_OK)
- session (f2) = a UUID, the bearer token for later calls
- key (f1) = 61 bytes: `02 || tag[16] || iv[12] || ciphertext[32]` (section 0)
- alternative_login_info.renewal_token was empty
`MyTeamSpeakIdData (f9)`: user_public_key 32 bytes, encrypted_user_private_key 112 bytes,
account_creation_time, my_teamspeak_id 33 bytes = `01 20 <32 bytes>` (version 1, length 32, id),
public_signature.
### 1c. Recovery / backup key
32 bytes, shown base64 in the account UI. Its use (unwrapping the data key without the password) is not
recovered.
### 1d. One encrypted sync item (a bookmark) with its KNOWN plaintext
From `POST /synchronization` `requestServerItems` reply,
`Sync_ItemClasses_Data_Detail { item_uuid=1 string, item_version=2 string, item_blob=3 bytes }`:
- item_uuid = `43c5d058-4803-3cd8-b844-15fdcc92e730`
- item_version = `5d831e3c-d5aa-0e66-7af2-a0feb13af048`
- item_blob (274 bytes, note leading `0x11`) =
`1114428e2059c038f339b1c1a6bc4eb0eac1f4479ec2764ea53c0b4dd0fc46264642cab8908d295933fac43152154f58990909e933d899b86d4776ae8c52f61575b33932d4667fc3fb50e78a2a056dc6c8cd74e3e2446275e0d351c7139bc686a3555b4dd3dd6fdfae1c7176e84f99b30df52dbe9b95e28d9545e2dd3188fec7e34ac233fb7de6d21db8795a45186ff91a01d23189d072d6afb60cad9b1c834e35acf04006daa1d6e439750afaa00a4c63e53f10cf54d2820108c1b859b394d0ba7d80b38de402b631da9ba4daedbff4d9de85cdeae7466b516ac5abe9af4d43e9df1c9bf8951d12e9f5c42ce7cf802443d4bc051902164ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8`
**Known plaintext** — after the official client logged in, it wrote this same bookmark DECRYPTED into its
local `settings.db` (`ProtobufItems` row 5). That decrypted `Item_Data` protobuf (231 bytes) is:
- hex =
`122434336335643035382d343830332d336364382d623834342d3135666463633932653733301a2464373663663736312d316163332d373434662d366234312d363636333863626431653033200330003a00420048f7f5dbd506820189010a19457269c48d516f76205465616d537065616b20736572766572120f7365727665722e6c69786b6f2e657518834e22056e696765722a00320744656661756c743a0042004a0050005a0744656661756c74620744656661756c746a0070007a1c2b547967324a7478453876524e5a702b4a6955426e6d4268304d593d8a0100900100980100b00101`
- decoded: `Item_Data{ item_uuid(2)="43c5d058-4803-3cd8-b844-15fdcc92e730",
version(3)="d76cf761-1ac3-744f-6b41-66638cbd1e03", manipulated(4)=3, item_type(6)=0 BOOKMARK,
timestamp(9)=…, bookmark(16)=Bookmark_Data{ name="EriÄQov TeamSpeak server", address="server.lixko.eu",
port=9987, nickname="niger", capture/playback/hotkey="Default", server_uid="+Tyg2JtxE8vRNZp+JiUBnmBh0MY=",
send_mytsid=1 } }`
**Caveat resolved:** the local copy's `sync_version_uuid` is `d76cf761-…` while the captured wire
`item_version` is `5d831e3c-…` because the local row is a later revision. Decrypting the captured ciphertext
nevertheless yields a valid 231-byte `Item_Data` for the same bookmark; its older revision has
`manipulated=ITEM_ADDED` (0), whereas the committed local copy has `ITEM_NOT_MANIPULATED` (3). The apparent
43-byte overhead was an artifact of a truncated capture: the 274 captured bytes contain IV + ciphertext
+ only 27 of the 64 SHA-512 bytes. The complete frame is 311 bytes.
---
## 2. What the binary tells us about the construction
Strings/asserts from the client binaries (`teamcrypto` + `cloud_sync_client`):
- teamcrypto exposes ONLY: `aes::Gcm`, `aes::Ctr`, `sha2::Sha2<mbedtls_sha256>`, `sha2::Sha2<mbedtls_sha512>`,
`drbg::DRBG_Block_Ctr`. (So HKDF/PBKDF2/scrypt/Argon2 strings elsewhere are from statically-linked
OpenSSL and are NOT used by the sync path.)
- `cloud_sync_client/src/lib/Encryption.cpp` asserts: `plain_hash.size() == teamcrypto::sha2::sha512_size`
(== 64). ⇒ a **SHA-512** digest ("plain_hash") is central to the item encryption/derivation.
- `cloud_sync_client/src/lib/Item_Manager.cpp` asserts: `!salt.empty()`. ⇒ items use a **salt**.
- `Account_Serializing.proto` has `Auth_Token_Package { auth_token, encrypted_encryption_password,
encryption_tag, iv }` and `Account_Data` carries `key`, `backup_ed_key`, per-class version UUIDs.
⇒ classic E2E model: a random **encryption_password** (data key) is AES-GCM-wrapped by a
password-derived key AND (separately) recoverable via the 32-byte recovery key.
- The account keypair (`user_public_key` 32B, `encrypted_user_private_key` 112B) suggests X25519/Ed25519;
112 bytes wrapped for a 32-byte key ⇒ ~ (12 nonce + 32 + 16 tag = 60?) no — 112 is large, so the
wrapped plaintext is likely more than the bare 32-byte key (maybe key+metadata, or salt-prefixed).
## 3. Historical hypotheses ruled out before the construction was recovered
Login token (had to reproduce both samples):
- Plain digests: SHA-256/384/512 (and truncations to 48) of pw, email+pw, pw+email, email:pw, with email
in raw and lowercased forms. **No.**
- HMAC-SHA256/384/512 (key=email or pw; msg=the other), truncated to 48. **No.**
- PBKDF2-HMAC-SHA1/256/384/512, iters ∈ {1,100,1000,2048,4096,5000,10000,20000,50000,100000}, dklen 48,
salt ∈ {email, lower(email), sha256(email), sha1(email), md5(email), "", "TeamSpeak", "myTeamSpeak",
"teamspeak", "teamspeak.com", "clientapi.myteamspeak.com", …, and each pepper ± email}. **No.**
- scrypt (N ∈ {1k…64k}, r=8, p=1, dklen 48) over the same pw/salt matrix. **No.**
- Argon2 i/d/id (t ∈ 1..4, m ∈ 8M..256M, dklen 48) over the same matrix. **No.** *(and Argon2 can't be
it anyway: the desktop client links OpenSSL 1.1.1 which lacks Argon2, yet must produce the same token.)*
- ~35 SHA-512-composite forms tested against BOTH samples: sha512(sha512(pw)+e), sha512(e+sha512(pw)),
sha512(sha512(e)+sha512(pw)), sha256(pw+e)+sha256(e+pw)[:16], xor forms, etc. **No.**
The missing salt components turned out to be the purpose strings `ts3Login`/`ts3Encryption` followed by
the password itself; the primitive is PBKDF2-HMAC-SHA512.
Item decryption:
- AES-256-GCM with key ∈ {recovery_key, sha256(rk), sha512(rk)[:32], sha512(rk)[32:]}, nonce at offset
0 or 1 (skipping the `0x11` byte), nonce len 12 or 16, tag = last 16 bytes, AAD ∈ {none, item_uuid
(ascii), item_version (ascii), uuid+version, 16-byte binary uuids, 0x11}. **No verify.**
- Non-AEAD (AES-CBC / AES-CTR / ChaCha20) with the same keys, checked against the known plaintext prefix
`1224` + "43c5d058-…". **No.**
The recovery key is not the item key. For password login, the item key is obtained by unwrapping
`LoginSession.key` as described in section 0.
## 4. Where the code is (for tool-driven RE with the binaries)
Binaries in this repo: `TeamSpeak3-Client-linux_amd64/ts3client_linux_amd64` (x86-64, links
`libcrypto.so.1.1`) and `re-android/resources/lib/arm64-v8a/libteamspeak_client.so` (arm64, NDK r28,
stripped; file offset == vaddr in .rodata/.text). Both are stripped; addresses below are from the arm64
lib unless noted.
- Encryption.cpp SHA-512 assert string @ vaddr `0x1baed6`; referenced by code @ `0xa8c280`; enclosing
function starts @ **`0xa8c198`** (0x1e0 stack frame). It reaches SHA-512/AES-GCM through a **vtable**
(interface object in x0): `ldr x8,[x0]; ldr x9,[x8,#0x18]; blr x9` (compute something → checks size==0x40)
then `ldr x9,[x8,#0x48]; blr x9`. So the crypto is behind `Encryption`'s dependency interface; resolving
it needs the vtable in .data.rel.ro or dynamic tracing.
- SHA-512 K-table @ vaddr `0x29f4d8` (SHA-256 K @ `0x29f318`, SHA-512 IV0 @ `0x241580`). SHA-512
processing code references the K page from ~`0xcbfe00`–`0xcc5990` (mbedtls sha512). Its callers (2 hops
up) include the login-token KDF and Encryption.
- Login entry: `Java_..._AccountManager_setupSyncAccount` @ `0x8931a8` takes (email, password, device),
converts the 3 Java Strings, then calls `Account_Manager_Impl` vtable slot `[vtable+0x18]`
(`0x8932d0: ldr x8,[x26]; ldr x8,[x8,#0x18]; blr x8`). Follow that to the LoginData builder → the KDF.
- Desktop x86-64: same assert string @ vaddr `0x2dcac1` — use a `lea rXX,[rip+…]` xref to find the
Encryption fn there, for gdb breakpoints on the running client.
### Disassembly excerpt — encryption fn prologue + the SHA-512-size check (arm64)
```
0xa8c198 sub sp, sp, #0x1e0
...
0xa8c1e4 ldr x0, [x0] ; deref interface obj
0xa8c1ec ldr x8, [x0] ; vtable
0xa8c1f4 ldr x9, [x8, #0x18] ; vfn @ +0x18 -> produces a digest into [sp,#0x48]
0xa8c1fc blr x9
0xa8c200 ldrb w8, [sp, #0x48] ; read std::string/vector size (SSO-tagged)
...
0xa8c218 cmp x9, #0x40 ; == 64 (teamcrypto::sha2::sha512_size) -> plain_hash
0xa8c224 b.ne #0xa8c270 ; else assert-fail (Encryption.cpp)
0xa8c228 ldr x0, [x21]
0xa8c234 ldr x9, [x8, #0x48] ; vfn @ +0x48 -> next crypto step (AES-GCM?)
0xa8c23c blr x9
```
## 5. Synchronization and upload state machine
This is a two-phase comparison/upload protocol. `requestServerItems` does **not** blindly upload every
local blob. Static tracing of the official Android x86-64 client shows that its initial request iterates
the local item snapshot and creates details containing only the local `Item_Data.item_uuid` and
`Item_Data.sync_version_uuid`. The latter is copied directly to wire field `item_version`; no blob is set
by that builder.
### 5a. Phase 1: advertise local state and receive the difference
Send `POST /synchronization`, framed with method `requestServerItems`, and a
`Sync_Request_ItemClasses`:
```
session = current LoginSession.session
globalversion = last global cursor returned by the server
sync_version = V1_1 (1) for the recovered current protocol
classes[] {
itemclass = BOOKMARK / IDENTITY / ...
version = last server cursor for this class
detail[] { item_uuid, item_version } // item_version = local sync_version_uuid
}
```
For an initial/full comparison the official flow can use the all-zero global UUID. Thereafter,
`globalversion` and each class `version` are opaque **server cursors**: retain the values in the reply and
send them back; do not invent UUIDs for either one.
The reply is the server's comparison result. Its class/details can contain remote `item_blob` values,
deletion instructions, and `not_in_db`. Interpret status as follows:
| Status | Meaning for the client |
| --- | --- |
| `300 IN_SYNC` | No second-phase work for this comparison. Commit the returned cursors. |
| `301 NOT_IN_SYNC` | Apply/download remote differences and/or submit the requested local changes in phase 2. |
| `302 NOT_IN_DB` | The advertised class/item is absent server-side; this is normally an upload candidate, subject to deletion state. |
| `303 COLLISION` | A cursor/base is stale or revisions conflict. Pull current state, resolve/merge, then retry with the newly returned cursors. |
| `304 OVERLIMIT` | Do not retry as a conflict. Surface the account/storage limit. |
The precise mixture of remote changes and requested uploads in a `NOT_IN_SYNC` reply should be treated as
server-directed reconciliation, not inferred solely from the top-level status. Match details by
`(itemclass, item_uuid)`.
### 5b. Phase 2: send the selected changes
Send the same protobuf type to the same endpoint, but frame it with method `synchronizeItems`. Echo the
server `globalversion` and per-class `version` from phase 1. For an add/change, a detail carries:
```
item_uuid = Item_Data.item_uuid
item_version = Item_Data.sync_version_uuid
item_blob = IV16 || AES-CTR-LE128(Item_Data bytes) || SHA512(Item_Data bytes)
delete_on_server = false / omitted
```
The encrypted plaintext is the complete serialized `Item_Data`, including its current `manipulated`
value. The older captured server blob decrypts with `ITEM_ADDED`, which is direct evidence that this flag
is not scrubbed before encryption.
For a deletion, the high-confidence wire mapping is a tombstone detail with the stable `item_uuid`, its
revision in `item_version`, `item_delete_on_server=true` (the recovered `.proto` misspells this
`item_delte_on_server`), and no meaningful `item_blob`. The schema and mutation paths agree on this
mapping, but the stripped second-stage detail builder has not yet been isolated instruction-for-instruction;
keep this one assertion behind a fixture/integration test before enabling destructive live sync.
Only mark submitted local items clean after a successful reply. Paths that accept server/committed state
set `Item_Data.manipulated=ITEM_NOT_MANIPULATED` (3). Persist the new global and class cursors atomically
with those clean-state transitions so a crash cannot acknowledge local changes without saving the cursor.
### 5c. Local mutation semantics recovered from `Item_Manager`
| Local operation | `item_uuid` | `sync_version_uuid` / wire `item_version` | `manipulated` |
| --- | --- | --- | --- |
| Add | Generate a new UUID | Generate a new UUID | `ITEM_ADDED` (0) |
| Edit an unsynced add | Preserve | Generation point not yet isolated | Remains `ITEM_ADDED` (0) |
| Edit a committed item | Preserve | Generation point not yet isolated | `ITEM_CHANGED` (1) |
| Delete | Preserve | A fresh UUID is generated in the observed tombstone path | `ITEM_DELETED` (2) |
| Accept remote/committed state | Preserve server identity | Use accepted revision | `ITEM_NOT_MANIPULATED` (3) |
The update transaction deliberately avoids changing an item already marked `ITEM_ADDED` or
`ITEM_DELETED`; this is why an edit before first upload is still an add, not a change. Do not assume that
`last_known_version` is interchangeable with `sync_version_uuid`. Field 5 is very likely the conflict/base
revision used during collision handling, but its exact wire lifecycle is not yet proven.
### 5d. Version ownership and retry rules
| Value | Owner / rule |
| --- | --- |
| `globalversion` | Server-owned global cursor; echo the latest reply. |
| class `version` | Server-owned per-class cursor; echo the latest reply. |
| `item_uuid` | Stable logical item identity; client-generated for a new item. |
| wire `item_version` | Exact projection of plaintext field 3, `sync_version_uuid`. |
| `last_known_version` | Probable conflict base; purpose is not proven enough to synthesize it. |
Never loop `synchronizeItems` with the same stale cursors after `COLLISION`. Re-run phase 1, decrypt and
compare the current remote item, apply the chosen merge policy, and construct a new mutation. The official
binary exposes a `solveCollision` path, but application-level winner/merge policy remains to be recovered.
### 5e. Static evidence (Android x86-64 build)
- Initial request builder `0xa39350` gets the local item list via `0xa47ab0`, reads `item_uuid` through
`0xa4cdc0`, reads `sync_version_uuid` through `0xa4ce50`, and copies them into detail fields 1 and 2.
- New-item initialization calls `0xa4cfe0` (new item UUID), `0xa4d0b0` (new sync-version UUID), then marks
the item `ITEM_ADDED`.
- Update transaction near `0xa45220` changes clean/changed items to `ITEM_CHANGED`, but preserves
`ITEM_ADDED` and `ITEM_DELETED`.
- The observed tombstone path marks `ITEM_DELETED` and calls `0xa4d0b0` for a fresh revision.
- Remote-accept/import paths near `0xa48896` and `0xa494xx` set `ITEM_NOT_MANIPULATED`.
These addresses are for `re-android/resources/lib/x86_64/libteamspeak_client.so`, not the ARM64 or
desktop library.
The transport, protobuf codec, login, download, encryption, and low-level `synchronizeItems` call are
recovered. The client implements the read-only pull (`com.ts3client.myts`, see IMPLEMENTATION.md);
two-way sync would still need the high-level reconciliation state machine, collision policy, and a safe
deletion fixture.

View File

@@ -0,0 +1,48 @@
# myTeamSpeak in the client
What is implemented, and what two-way sync would still need.
## Sign-in and read-only import (done)
`core/src/main/java/com/ts3client/myts/` — pure Java, no Swing/Android dependency, and no JDK API
that Android 13 lacks (hence `HttpURLConnection`, not `java.net.http`).
- `MyTeamSpeakLogin` — the account the client stays signed in to. Like the official client's
`Account_Data` (which stores email, the login token in its `password` field, and the account key as
`encryption_password`), it keeps `MyTeamSpeak.Credentials` — email, login token, account key — in the
private profile file `myteamspeak.properties`; never the password. `signIn` derives them, reads the
account and only then saves them; `fetch` reads the account again with the saved ones; `signOut`
deletes the file. When the server rejects saved credentials (`ERROR_LOGIN_FAILED`, e.g. the password
was changed elsewhere) `fetch` signs out.
- `MyTeamSpeak.download(Credentials)` — signs in (`authentication/login`), unwraps the item key, pulls
BOOKMARK, IDENTITY and ITEM_FOLDER with one `synchronization/requestServerItems` against an empty
local state (all-zero global and class versions, no details), decrypts every item, then ends the
session (`authentication/deleteSession`). Each read signs in afresh; no server session is kept.
- `MyTsCrypto` — PBKDF2-HMAC-SHA512 login token and account key, `LoginSession.key` unwrap (AES-256-GCM),
item frame decryption (little-endian AES-CTR + SHA-512 trailer). See `CRYPTO_RE_SALT.md`.
- `MyTsTransport` — the `application/ts3cloud` framing over HTTP/1.1; an interface so tests replay a server.
- Decrypted items are plain `Item_Data`, decoded by `teamspeak/SyncItemDecoder`. `TeamSpeakImporter`
gains `importSelected(all, chosen)` (chosen bookmarks bring the identities they use) and
`isPresent(item)`; the policy is the local settings.db import's: additive and idempotent.
Frontends: Swing Options → "myTeamSpeak" tab (`MyTeamSpeakPanel`); Android Settings → Account →
myTeamSpeak (`ui/MyTeamSpeakScreen.kt`). Both: sign in/out, the account's bookmarks and identities with a
checkbox each and whether they are imported already, Refresh, "Import selected".
Observed on the live account: a bookmark may name its identity by the identity's *name* ("Default") instead
of its item UUID; the importer resolves both.
Tests: `MyTsCryptoTest` (captured vectors), `MyTeamSpeakTest` (download against a replayed server built from
the captured login and item), `MyTeamSpeakLoginTest` (persistence, failed sign-in, rejected credentials).
## Two-way sync (not done)
Would additionally need the server's global and per-class version cursors and each item's
`item_uuid`/`sync_version_uuid` persisted, and a mapping between our bookmarks/identities and those items. The request/reply state machine,
mutation table, and item encryption (`IV || CTR || SHA-512`, random IV) are in `CRYPTO_RE_SALT.md` section 5. Open points: when edits rotate `sync_version_uuid`, the role of
`last_known_version`, the collision merge policy, and confirming the deletion tombstone before anything
destructive is sent.
## Etiquette
One sign-in is three requests. Don't loop sign-ins while testing; the service sits behind Cloudflare and
rate-limits abuse.

View File

@@ -0,0 +1,81 @@
# myTeamSpeak client protocol (reverse-engineered)
Recovered from the official TS3 client binaries (`TeamSpeak3-Client-linux_amd64/ts3client_linux_amd64`
and `re-android/.../libteamspeak_client.so`) plus a captured real login/sync session. This documents
what is needed to talk to myTeamSpeak for synchronization of bookmarks/identities/etc.
## Transport
Not gRPC-over-HTTP2 in the usual sense. It is **HTTP/1.1 POST** (cpp-httplib client) to:
https://clientapi.myteamspeak.com/<endpoint>
Endpoints: `authentication`, `session`, `synchronization`, `user`, `integration`, `messenger`, `tschat`, `addon`.
Headers: `Content-Type: application/ts3cloud`, `Accept: */*`, `Connection: close`,
`User-Agent: cpp-httplib/0.11.1`. Cloudflare fronts it and 403s anything that doesn't look right.
Request body framing:
<1 byte: len of method name> <method-name ASCII> <serialized protobuf request>
e.g. `05 "login" <LoginData>`, `12 "requestServerItems" <Sync_Request_ItemClasses>`.
Response body = the serialized protobuf reply (Content-Type comes back `application/json` but the
body is protobuf, not JSON).
## Services / methods (see myteamspeak.recovered.proto for full schemas)
- **LoginService** (`/authentication`): `login(LoginData) -> LoginSession`,
`loginWithAuthToken`, `loginWithRenewalToken`, `session`, `deleteSession`, `requestAuthToken`.
- **SynchronizationService** (`/synchronization`):
`requestServerItems(Sync_Request_ItemClasses) -> Sync_Reply_ItemClasses` (download),
`synchronizeItems(...)` (upload/merge).
### Login flow (observed)
1. POST `/authentication` `login` with `LoginData{email(1), password(2)}`.
`password` is NOT the plaintext — see Crypto below.
2. Reply `LoginSession`: `key(1)`, `session(2)=UUID` (bearer for later calls),
`limits(3)`, `uuid(4)`, `error(5)=200` on success (ErrorCommon.ERROR_LOGIN_OK),
`purge(6)`, `username(8)`, `myts_id_data(9)=MyTeamSpeakIdData`, `mytsid_user_cert(10)`,
`alternative_login_info(16).renewal_token`.
3. POST `/synchronization` `requestServerItems` with `{session(1), classes(2), globalversion(3)}`.
Advertise each local item as `{item_uuid(1), item_version(2)}` where `item_version` is exactly the
local plaintext `Item_Data.sync_version_uuid`; the initial request builder does not attach blobs.
4. Reconcile the server's comparison reply. Decrypt any returned `item_blob`, honor deletion/not-in-db
details, and retain its opaque global and per-class version cursors.
5. If local changes are requested, POST `synchronizeItems` with those reply cursors. Adds/changes carry
the full encrypted `Item_Data` frame; deletions use the detail deletion flag. See
[CRYPTO_RE_SALT.md](CRYPTO_RE_SALT.md#5-synchronization-and-upload-state-machine) for the recovered
state machine and confidence boundaries.
## Crypto status
myTeamSpeak sync is **end-to-end encrypted**, using TeamSpeak's own `teamcrypto` library
(AES-256-GCM + SHA-2 via mbedtls + a CTR-DRBG), statically linked — NOT OpenSSL's KDFs and
NOT libsodium. This is why the sync store on disk is plaintext but the wire blobs are not.
Recovered constructions:
- The login `password` field is base64 of 48 bytes from PBKDF2-HMAC-SHA512 with 10,000 iterations:
password=`password`, salt=`asciiLower(email) + "ts3Login" + password`.
- The account wrapping key is the same KDF with salt purpose `ts3Encryption` and a 32-byte output.
- `LoginSession.key` v2 is `02 || GCM-tag[16] || IV[12] || ciphertext[32]`, AES-256-GCM without AAD.
Its plaintext is the 32-byte item key.
- `MyTeamSpeakIdData`: `user_public_key(1)` = 32 bytes (X25519/Ed25519),
`user_private_key(2).encrypted_user_private_key` = 112 bytes (the account private key, wrapped
by a password-derived key), `account_creation_time(3)`, `my_teamspeak_id(4)` = `01 20 <32 bytes>`,
`public_signature(5)`.
- `Account_Data` / `Auth_Token_Package` show the recovery model:
`encrypted_encryption_password`, `encryption_tag`, `iv` — a random data key wrapped by the
password-derived key, and separately by the **recovery key** (32 bytes, base64), so the recovery
key is the route to decrypt items without the password.
- Item blobs are `IV[16] || AES-256-CTR ciphertext || SHA512(plaintext)`. CTR increments its full
128-bit counter little-endian. The original capture contained the complete ciphertext but only the
first 27 of 64 digest bytes; those bytes exactly match the recovered plaintext hash.
## Remaining reverse-engineering gaps
Password login, key unwrap, item encryption/integrity framing, two-phase comparison, and the local
manipulation flags are recovered. The remaining uncertainties are narrower: where ordinary edits rotate
`sync_version_uuid`, the exact lifecycle of `last_known_version`, the official collision winner/merge
policy, and instruction-level confirmation of the deletion-detail builder. Recovery-key import is also
not yet recovered. The read-only pull is implemented in `com.ts3client.myts` (see IMPLEMENTATION.md); destructive upload
should remain gated until the deletion mapping has a fixture or controlled integration test.

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.myts.MyTeamSpeakLogin;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode;
@@ -55,6 +56,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Bookmarks bookmarks = Bookmarks.load();
private final AwayMessages awayMessages = AwayMessages.load();
private final IdentityStore identities;
private final MyTeamSpeakLogin myTeamSpeak = MyTeamSpeakLogin.load();
/** Friends and blocked clients, shared by every connection. */
private final ContactStore contacts = ContactStore.load();
private final AudioBackend audio = new DesktopAudioBackend();
@@ -787,6 +789,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
sessions.microphoneOwner() == null ? null : sessions.microphoneOwner().connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
sounds, hotkeys,
new MyTeamSpeakPanel(myTeamSpeak, new TeamSpeakImporter(identities, bookmarks, settings),
menuBar::rebuildBookmarks),
() -> {
applyOutputSettingsToAllTabs();
contactsChanged();

View File

@@ -0,0 +1,352 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.myts.MyTeamSpeak;
import com.ts3client.myts.MyTeamSpeakLogin;
import com.ts3client.myts.MyTsException;
import com.ts3client.teamspeak.SyncItem;
import com.ts3client.teamspeak.TeamSpeakImporter;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Options page for the myTeamSpeak account: sign in and out, and see what the
* account synchronises and import it. Everything here takes effect at once rather
* than on OK, as signing in is not a setting to be cancelled.
*/
final class MyTeamSpeakPanel extends JPanel {
private static final String SIGNED_OUT = "out";
private static final String SIGNED_IN = "in";
private final MyTeamSpeakLogin login;
private final TeamSpeakImporter importer;
private final Runnable onImported;
private final CardLayout cards = new CardLayout();
private final JTextField email = new JTextField(24);
private final JPasswordField password = new JPasswordField(24);
private final JButton signIn = new JButton("Sign in");
private final JLabel signInStatus = new JLabel(" ");
private final JLabel account = new JLabel();
private final ItemTable items = new ItemTable();
private final JButton refresh = new JButton("Refresh");
private final JButton importButton = new JButton("Import selected");
private final JLabel dataStatus = new JLabel(" ");
private List<SyncItem> accountItems = List.of();
private boolean fetched;
MyTeamSpeakPanel(MyTeamSpeakLogin login, TeamSpeakImporter importer, Runnable onImported) {
this.login = login;
this.importer = importer;
this.onImported = onImported;
setLayout(cards);
add(buildSignedOut(), SIGNED_OUT);
add(buildSignedIn(), SIGNED_IN);
showState();
// Reads the account the first time the page is looked at, not with every Options dialog.
addHierarchyListener(e -> {
if (isShowing() && login.isSignedIn() && !fetched) fetch();
});
}
private JPanel buildSignedOut() {
JPanel form = new JPanel(new GridBagLayout());
form.setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = FormPanel.gbc();
c.gridwidth = 2;
c.gridy = 0;
form.add(new JLabel("<html>Sign in to your myTeamSpeak account to bring over the bookmarks and "
+ "identities it synchronises. The client stays signed in; your password itself is not kept.</html>"), c);
c.gridwidth = 1;
FormPanel.addRow(form, c, 1, new JLabel("Email:"), email);
FormPanel.addRow(form, c, 2, new JLabel("Password:"), password);
c.gridx = 1;
c.gridy = 3;
c.fill = GridBagConstraints.NONE;
c.anchor = GridBagConstraints.EAST;
form.add(signIn, c);
c.gridx = 0;
c.gridy = 4;
c.gridwidth = 2;
c.fill = GridBagConstraints.HORIZONTAL;
form.add(signInStatus, c);
c.gridy = 5;
c.weighty = 1;
form.add(Box.createVerticalGlue(), c);
signIn.addActionListener(e -> signIn());
password.addActionListener(e -> signIn());
return form;
}
private JPanel buildSignedIn() {
JPanel header = new JPanel(new BorderLayout());
JButton signOut = new JButton("Sign out");
signOut.addActionListener(e -> signOut());
header.add(account, BorderLayout.CENTER);
header.add(signOut, BorderLayout.EAST);
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS));
buttons.add(dataStatus);
buttons.add(Box.createHorizontalGlue());
buttons.add(refresh);
buttons.add(Box.createHorizontalStrut(UIScale.scale(4)));
buttons.add(importButton);
refresh.addActionListener(e -> fetch());
importButton.addActionListener(e -> importSelected());
JPanel data = new JPanel(new BorderLayout(0, UIScale.scale(4)));
data.add(new JLabel("Synchronised by the account:"), BorderLayout.NORTH);
data.add(new JScrollPane(items.table), BorderLayout.CENTER);
data.add(buttons, BorderLayout.SOUTH);
JPanel panel = new JPanel(new BorderLayout(0, UIScale.scale(12)));
panel.setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
panel.add(header, BorderLayout.NORTH);
panel.add(data, BorderLayout.CENTER);
return panel;
}
private void showState() {
if (login.isSignedIn()) {
account.setText("<html>Signed in as <b>" + escape(login.username()) + "</b><br>"
+ escape(login.email()) + "</html>");
cards.show(this, SIGNED_IN);
} else {
password.setText("");
cards.show(this, SIGNED_OUT);
}
}
private void signIn() {
String address = email.getText().trim();
String secret = new String(password.getPassword());
if (address.isEmpty() || secret.isEmpty() || !signIn.isEnabled()) return;
setSignInBusy(true);
status(signInStatus, "Signing in…", false);
background(() -> {
MyTeamSpeak.Account result = login.signIn(address, secret);
SwingUtilities.invokeLater(() -> {
setSignInBusy(false);
status(signInStatus, " ", false);
showState();
showItems(result);
});
}, message -> {
setSignInBusy(false);
status(signInStatus, message, true);
password.selectAll();
password.requestFocusInWindow();
});
}
private void signOut() {
login.signOut();
accountItems = List.of();
items.set(List.of());
fetched = false;
email.setText("");
showState();
}
private void fetch() {
fetched = true;
setDataBusy(true);
status(dataStatus, "Reading the account…", false);
background(() -> {
MyTeamSpeak.Account result = login.fetch();
SwingUtilities.invokeLater(() -> {
setDataBusy(false);
showState();
showItems(result);
});
}, message -> {
setDataBusy(false);
status(dataStatus, message, true);
// A rejected sign-in has signed out.
showState();
if (!login.isSignedIn()) status(signInStatus, message, true);
});
}
private void showItems(MyTeamSpeak.Account result) {
fetched = true;
accountItems = result.items();
List<Row> rows = new ArrayList<>();
for (SyncItem item : accountItems) {
if (item instanceof SyncItem.Folder) continue;
boolean present = importer.isPresent(item);
rows.add(new Row(item, present, !present));
}
items.set(rows);
status(dataStatus, rows.isEmpty() ? "The account holds no bookmarks or identities." : " ", false);
updateImportButton();
}
private void importSelected() {
List<SyncItem> chosen = items.rows.stream().filter(Row::selected).map(Row::item).toList();
if (chosen.isEmpty()) return;
try {
TeamSpeakImporter.Result r = importer.importSelected(accountItems, chosen);
status(dataStatus, "Imported " + count(r.identitiesAdded(), "identity", "identities") + " and "
+ count(r.bookmarksAdded(), "bookmark", "bookmarks") + ".", false);
if (onImported != null) onImported.run();
} catch (IOException e) {
status(dataStatus, "Could not import: " + e.getMessage(), true);
}
List<Row> rows = new ArrayList<>();
for (Row row : items.rows) rows.add(new Row(row.item(), importer.isPresent(row.item()), false));
items.set(rows);
updateImportButton();
}
private interface Work {
void run() throws IOException, MyTsException;
}
/** Runs account work off the event thread; a failure's message reaches {@code onError} on it. */
private static void background(Work work, java.util.function.Consumer<String> onError) {
Thread.ofVirtual().name("myteamspeak").start(() -> {
try {
work.run();
} catch (MyTsException e) {
SwingUtilities.invokeLater(() -> onError.accept(e.getMessage()));
} catch (IOException e) {
SwingUtilities.invokeLater(() -> onError.accept("Could not reach myTeamSpeak: " + e.getMessage()));
}
});
}
private void setSignInBusy(boolean busy) {
email.setEnabled(!busy);
password.setEnabled(!busy);
signIn.setEnabled(!busy);
}
private void setDataBusy(boolean busy) {
refresh.setEnabled(!busy);
importButton.setEnabled(!busy);
if (!busy) updateImportButton();
}
private void updateImportButton() {
importButton.setEnabled(refresh.isEnabled() && items.rows.stream().anyMatch(Row::selected));
}
private static void status(JLabel label, String text, boolean error) {
label.setText(text.isBlank() ? " " : "<html>" + escape(text) + "</html>");
label.setForeground(UIManager.getColor(error ? "Actions.Red" : "Label.foreground"));
}
private static String escape(String s) {
return s.replace("&", "&amp;").replace("<", "&lt;");
}
private static String count(long n, String one, String many) {
return n + " " + (n == 1 ? one : many);
}
private record Row(SyncItem item, boolean present, boolean selected) {
}
/** The account's items, each with a box to pick it for importing and whether it is here already. */
private final class ItemTable extends AbstractTableModel {
private static final String[] COLUMNS = {"", "Type", "Name", "Details", "Imported"};
final JTable table = new JTable(this);
List<Row> rows = List.of();
ItemTable() {
table.setFillsViewportHeight(true);
table.getTableHeader().setReorderingAllowed(false);
table.getColumnModel().getColumn(0).setMaxWidth(UIScale.scale(28));
table.getColumnModel().getColumn(1).setPreferredWidth(UIScale.scale(60));
table.getColumnModel().getColumn(4).setPreferredWidth(UIScale.scale(60));
}
void set(List<Row> rows) {
this.rows = rows;
fireTableDataChanged();
}
@Override
public int getRowCount() {
return rows.size();
}
@Override
public int getColumnCount() {
return COLUMNS.length;
}
@Override
public String getColumnName(int column) {
return COLUMNS[column];
}
@Override
public Class<?> getColumnClass(int column) {
return column == 0 ? Boolean.class : String.class;
}
@Override
public boolean isCellEditable(int row, int column) {
return column == 0;
}
@Override
public Object getValueAt(int row, int column) {
Row r = rows.get(row);
return switch (column) {
case 0 -> r.selected();
case 1 -> r.item() instanceof SyncItem.Bookmark ? "Bookmark" : "Identity";
case 2 -> switch (r.item()) {
case SyncItem.Bookmark b -> b.name();
case SyncItem.Identity i -> i.name();
default -> "";
};
case 3 -> switch (r.item()) {
case SyncItem.Bookmark b -> b.address() + ":" + b.port();
case SyncItem.Identity i -> i.nickname();
default -> "";
};
default -> r.present() ? "Yes" : "";
};
}
@Override
public void setValueAt(Object value, int row, int column) {
Row r = rows.get(row);
List<Row> updated = new ArrayList<>(rows);
updated.set(row, new Row(r.item(), r.present(), Boolean.TRUE.equals(value)));
rows = updated;
fireTableRowsUpdated(row, row);
updateImportButton();
}
}
}

View File

@@ -42,7 +42,8 @@ public final class SettingsDialog extends JDialog {
public SettingsDialog(Frame owner, Settings settings, AudioBackend audio,
VoiceInput liveMic, VoiceOutput livePlayback,
SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) {
SoundNotifier sounds, HotkeyService hotkeys, MyTeamSpeakPanel myTeamSpeak,
Runnable onApply) {
super(owner, "Options", true);
this.settings = settings;
this.liveMic = liveMic;
@@ -69,6 +70,7 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Contacts", contactsPanel);
tabs.addTab("Chat", scrollable(chatLogsPanel));
tabs.addTab("Client Version", scrollable(clientVersionPanel));
tabs.addTab("myTeamSpeak", myTeamSpeak);
JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel();