diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index af77253..984fde0 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -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, chosen: List): TeamSpeakImporter.Result = + withContext(Dispatchers.IO) { importer.importSelected(all, chosen) } + .also { _bookmarks.value = bookmarkStore.all().toList() } + // ---- the local client ---- fun hasMicPermission() = diff --git a/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt new file mode 100644 index 0000000..2f78598 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt @@ -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(null) } + var error by remember { mutableStateOf(false) } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var all by remember { mutableStateOf(emptyList()) } + var rows by remember { mutableStateOf(emptyList()) } + 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}" diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index a389c01..afece6b 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -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) + } } } } diff --git a/ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeak.java b/ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeak.java new file mode 100644 index 0000000..5b4cf7a --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeak.java @@ -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. + * + *

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 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 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 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 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 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); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeakLogin.java b/ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeakLogin.java new file mode 100644 index 0000000..61fbbaf --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeakLogin.java @@ -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. + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/myts/MyTsCrypto.java b/ts3-client/core/src/main/java/com/ts3client/myts/MyTsCrypto.java new file mode 100644 index 0000000..5e3a60b --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/myts/MyTsCrypto.java @@ -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 + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/myts/MyTsException.java b/ts3-client/core/src/main/java/com/ts3client/myts/MyTsException.java new file mode 100644 index 0000000..b8ee406 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/myts/MyTsException.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/myts/MyTsTransport.java b/ts3-client/core/src/main/java/com/ts3client/myts/MyTsTransport.java new file mode 100644 index 0000000..2f781a3 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/myts/MyTsTransport.java @@ -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 } + * 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(); + } + }; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java b/ts3-client/core/src/main/java/com/ts3client/proto/ProtobufReader.java similarity index 85% rename from ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java rename to ts3-client/core/src/main/java/com/ts3client/proto/ProtobufReader.java index 651ea07..89b88d7 100644 --- a/ts3-client/core/src/main/java/com/ts3client/teamspeak/ProtobufReader.java +++ b/ts3-client/core/src/main/java/com/ts3client/proto/ProtobufReader.java @@ -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; diff --git a/ts3-client/core/src/main/java/com/ts3client/proto/ProtobufWriter.java b/ts3-client/core/src/main/java/com/ts3client/proto/ProtobufWriter.java new file mode 100644 index 0000000..fa8fbcf --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/proto/ProtobufWriter.java @@ -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); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java index 3483aef..47fc5fc 100644 --- a/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItem.java @@ -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 */ diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java index a35ac53..e767661 100644 --- a/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/SyncItemDecoder.java @@ -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; diff --git a/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java index c312d7a..a97e923 100644 --- a/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java +++ b/ts3-client/core/src/main/java/com/ts3client/teamspeak/TeamSpeakImporter.java @@ -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 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 all, Collection chosen) throws IOException { + Map syncIdentities = identitiesOf(all); + Set 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 items, boolean withBookmarks, boolean onlyUsedIdentities) throws IOException { - Map syncIdentities = new LinkedHashMap<>(); + Map syncIdentities = identitiesOf(items); List 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 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 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 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 identitiesOf(List items) { + Map 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 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() diff --git a/ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakLoginTest.java b/ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakLoginTest.java new file mode 100644 index 0000000..a42dfed --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakLoginTest.java @@ -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()); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakTest.java b/ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakTest.java new file mode 100644 index 0000000..ebe0eee --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakTest.java @@ -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 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)); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/myts/MyTsCryptoTest.java b/ts3-client/core/src/test/java/com/ts3client/myts/MyTsCryptoTest.java new file mode 100644 index 0000000..16d97e8 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/myts/MyTsCryptoTest.java @@ -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); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java index 2629499..91e99a0 100644 --- a/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/teamspeak/TeamSpeakImporterTest.java @@ -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 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(); diff --git a/ts3-client/docs/myteamspeak/CRYPTO_RE.md b/ts3-client/docs/myteamspeak/CRYPTO_RE.md new file mode 100644 index 0000000..4d47f22 --- /dev/null +++ b/ts3-client/docs/myteamspeak/CRYPTO_RE.md @@ -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 ` — capstone arm64 disassembler, annotates adrp+add string loads and + bl targets with .symtab/.dynsym names. +- `xref.py ` / `xrefaddr.py [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]]. diff --git a/ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md b/ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md new file mode 100644 index 0000000..5b92ef2 --- /dev/null +++ b/ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md @@ -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" `, 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`, `sha2::Sha2`, + `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. diff --git a/ts3-client/docs/myteamspeak/IMPLEMENTATION.md b/ts3-client/docs/myteamspeak/IMPLEMENTATION.md new file mode 100644 index 0000000..578653f --- /dev/null +++ b/ts3-client/docs/myteamspeak/IMPLEMENTATION.md @@ -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. diff --git a/ts3-client/docs/myteamspeak/PROTOCOL.md b/ts3-client/docs/myteamspeak/PROTOCOL.md new file mode 100644 index 0000000..ce88a6e --- /dev/null +++ b/ts3-client/docs/myteamspeak/PROTOCOL.md @@ -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/ + +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> + +e.g. `05 "login" `, `12 "requestServerItems" `. +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. diff --git a/ts3-client/docs/myteamspeak/myteamspeak.recovered.proto b/ts3-client/docs/myteamspeak/myteamspeak.recovered.proto new file mode 100644 index 0000000..4af6224 --- /dev/null +++ b/ts3-client/docs/myteamspeak/myteamspeak.recovered.proto @@ -0,0 +1,1213 @@ +// file myteamspeak_integration_common.proto +package com.teamspeak.myteamspeak.proto.integration; +message UserIntegrationSubscriptionInfoRequestData { + .com.teamspeak.myteamspeak.proto.integration.IntegrationInfo integrations = 1; + bytes signature = 2; + uint64 timestamp = 3; + string virtualserver_id = 4; +} +message IntegrationResponse { + bytes integration_id = 1; + .com.teamspeak.myteamspeak.proto.integration.IntegrationType type = 2; + string special_information = 3; + uint64 cache_valid_timestamp = 4; + .com.teamspeak.myteamspeak.proto.integration.ResponseSpecifier response_specifier = 5; +} +message IntegrationResponseListData { + repeated .com.teamspeak.myteamspeak.proto.integration.IntegrationResponse list = 1; +} +message IntegrationResponseList { + .com.teamspeak.myteamspeak.proto.integration.IntegrationResponseListData data = 1; + bytes signature = 2; + bytes signing_certificate = 3; + uint64 timestamp = 4; +} +message IntegrationInfo { + repeated .com.teamspeak.myteamspeak.proto.integration.IntegrationInfo.Entry entry = 1; + message Entry { + bytes integration_id = 1; + .com.teamspeak.myteamspeak.proto.integration.IntegrationType type = 2; + } +} +message ResponseSpecifier { + .com.teamspeak.myteamspeak.proto.integration.SpecifierType specifier_type = 1; + string value = 2; +} +enum SpecifierType { + SPECIFIER_TYPE_INVALID = 0; + SUBSCRIPTION_TYPE = 1; + RELATIONSHIP_TYPE = 2; + TEST_RESPONSE_TYPE = 100; + ANOTHER_TEST_RESPONSE_TYPE = 101; +} +enum IntegrationType { + INTEGRATION_TYPE_INVALID = 0; + TWITCH = 1; + TEST_INTEGRATION = 100; +} +enum IntegrationBindingStatus { + STATUS_INVALID = 0; + STATUS_NOT_BOUND = 1; + STATUS_BOUND = 2; + STATUS_BINDING_IN_PROGRESS = 3; +} +enum IntegrationError { + INTEGRATION_ERROR_INVALID = 0; + INTEGRATION_ERROR_OK = 1; + INTEGRATION_ERROR_INVALID_USER = 2; + INTEGRATION_ERROR_INVALID_INTEGRATION = 3; + INTEGRATION_ERROR_INVALID_INTEGRATION_ID = 4; + INTEGRATION_ERROR_INVALID_PARAMETER = 5; + INTEGRATION_ERROR_INTERNAL = 6; + INTEGRATION_INFO_NOT_PRESENT_YET = 7; + INTEGRATION_ERROR_SESSION_EXPIRED = 8; + INTEGRATION_ERROR_SERVER_NOT_REACHABLE = 9; + INTEGRATION_ERROR_PENDING_PROCESS = 10; + INTEGRATION_ERROR_INVALID_TIMESTAMP = 11; +} + +// file myteamspeak_integration_user.proto +package com.teamspeak.myteamspeak.proto.integration; +import "myteamspeak_integration_common.proto"; +message RequestIntegrationUserStatus { + string session = 1; +} +message IntegrationUserDataEntry { + .com.teamspeak.myteamspeak.proto.integration.IntegrationBindingStatus binding_status = 1; + string integration_text = 4; + .com.teamspeak.myteamspeak.proto.integration.IntegrationType type = 5; + bytes integration_id = 6; +} +message IntegrationUserData { + repeated .com.teamspeak.myteamspeak.proto.integration.IntegrationUserDataEntry integrations = 1; +} +message IntegrationStatusUserResponse { + .com.teamspeak.myteamspeak.proto.integration.IntegrationError error = 1; + .com.teamspeak.myteamspeak.proto.integration.IntegrationUserData data = 2; +} +message RequestBindData { + string session = 1; + .com.teamspeak.myteamspeak.proto.integration.RequestBind data = 2; +} +message RequestBind { + bytes integration_id = 1; + .com.teamspeak.myteamspeak.proto.integration.IntegrationType type = 2; + bytes virtualserver_key = 3; + bytes signature = 4; + uint64 timestamp = 5; + string virtualserver_id = 6; +} +message RequestUnbindData { + string session = 1; + .com.teamspeak.myteamspeak.proto.integration.RequestUnbind data = 2; +} +message RequestUnbind { + bytes integration_id = 1; + bytes signature = 2; + uint64 timestamp = 3; + string virtualserver_id = 4; +} +message RequestBindResponse { + .com.teamspeak.myteamspeak.proto.integration.IntegrationError error = 1; +} +message UserIntegrationSubscriptionInfoRequest { + string session_id = 1; + .com.teamspeak.myteamspeak.proto.integration.UserIntegrationSubscriptionInfoRequestData data = 2; +} +message UserIntegrationSubscriptionInfoResponse { + .com.teamspeak.myteamspeak.proto.integration.IntegrationError error = 1; + uint32 seconds_to_wait = 2; + .com.teamspeak.myteamspeak.proto.integration.IntegrationResponseList integration_response = 3; +} +service IntegrationUserService { + rpc getIntegrationUserStatus(.com.teamspeak.myteamspeak.proto.integration.RequestIntegrationUserStatus) returns (.com.teamspeak.myteamspeak.proto.integration.IntegrationStatusUserResponse); + rpc requestBindIntegration(.com.teamspeak.myteamspeak.proto.integration.RequestBindData) returns (.com.teamspeak.myteamspeak.proto.integration.RequestBindResponse); + rpc requestUnbindIntegration(.com.teamspeak.myteamspeak.proto.integration.RequestUnbindData) returns (.com.teamspeak.myteamspeak.proto.integration.RequestBindResponse); + rpc getUserIntegrationSubscriptionInfo(.com.teamspeak.myteamspeak.proto.integration.UserIntegrationSubscriptionInfoRequest) returns (.com.teamspeak.myteamspeak.proto.integration.UserIntegrationSubscriptionInfoResponse); +} + +// file myteamspeak_addon.proto +package com.teamspeak.myteamspeak.proto.addon; +message GetDownload { + string addonUUID = 1; + int32 currentVersion = 2; + .com.teamspeak.myteamspeak.proto.addon.Platform platform = 3; + .com.teamspeak.myteamspeak.proto.addon.ClientApi clientapi = 4; + .com.teamspeak.myteamspeak.proto.addon.ClientApi minClientapi = 5; +} +message Download { + .com.teamspeak.myteamspeak.proto.addon.FileData downloadInfo = 1; + int32 currentVersion = 2; + .com.teamspeak.myteamspeak.proto.addon.ReturnCode returnCode = 3; + repeated string addonList = 4; + string hostUrl = 5; +} +message ClientApi { + int32 plugin = 1; + int32 style = 2; + int32 soundpack = 3; + int32 translation = 4; + int32 iconpack = 5; +} +message FileData { + string url = 1; + int32 size = 2; + string sha1 = 3; +} +enum ApiType { + UNKNOWN_APITYPE = 0; + PLUGIN = 1; + STYLE = 2; + SOUNDPACK = 3; + TRANSLATION = 4; + ICONPACK = 5; + GROUPADDON = 6; +} +enum ReturnCode { + UNKNOWN_RETURNCODE = 0; + ADDON_NOT_FOUND = 1; + PLATFORM_NOT_FOUND = 2; + UPTODATE = 3; + UPDATE = 4; + GROUP = 5; +} +enum Platform { + UNKNOWN_PLATFORM = 0; + WINDOWS_X86 = 1; + WINDOWS_X86_64 = 2; + LINUX_X86 = 3; + LINUX_X86_64 = 4; + OSX_X86 = 5; + OSX_X86_64 = 6; +} +enum Status { + UNKNOWN_STATUS = 0; + DRAFT = 1; + PENDING = 2; + APPROVED = 3; + REJECTED = 4; + REVIEW = 5; + TRASHED = 6; +} +service UserAddonService { + rpc requestDownload(.com.teamspeak.myteamspeak.proto.addon.GetDownload) returns (.com.teamspeak.myteamspeak.proto.addon.Download); +} + +// file myteamspeak_common.proto +package com.teamspeak.myteamspeak.proto; +import "myteamspeak_avatar_common.proto"; +import "synchronization_common.proto"; +message Limits { + uint32 limitID = 1; + int32 value = 2; +} +message LoginData { + string email = 1; + string password = 2; + bool skipSession = 3; + .com.teamspeak.myteamspeak.proto.LoginOrigin origin = 4; + .com.teamspeak.myteamspeak.proto.synchronization.Sync_Version sync_version = 5; +} +message MyTeamSpeakIdData { + bytes user_public_key = 1; + .com.teamspeak.myteamspeak.proto.MyTeamSpeakIdData.UserPrivateKey user_private_key = 2; + uint64 account_creation_time = 3; + bytes my_teamspeak_id = 4; + bytes public_signature = 5; + message UserPrivateKey { + bytes encrypted_user_private_key = 1; + bytes decrypted_user_private_key = 2; + } +} +message MyTSUserCertificate { + bytes cert = 1; +} +message LoginSession { + bytes key = 1; + string session = 2; + string uuid = 4; + repeated .com.teamspeak.myteamspeak.proto.Limits limits = 3; + int64 purge = 6; + .com.teamspeak.myteamspeak.proto.AddonDevStatus addon_dev = 7; + string username = 8; + .com.teamspeak.myteamspeak.proto.ErrorCommon error = 5; + .com.teamspeak.myteamspeak.proto.MyTeamSpeakIdData myts_id_data = 9; + .com.teamspeak.myteamspeak.proto.MyTSUserCertificate mytsid_user_cert = 10; + .com.teamspeak.myteamspeak.proto.AvatarData user_avatar = 11; + string push_token = 12; + .com.teamspeak.myteamspeak.proto.Permissions permission = 13; + .com.teamspeak.myteamspeak.proto.UserData user_data = 14; + .com.teamspeak.myteamspeak.proto.LoginSession.AlternativeLoginInformation alternative_login_info = 16; + message AlternativeLoginInformation { + string renewal_token = 1; + } +} +message Session { + string session = 1; +} +message EncryptionKey { + .com.teamspeak.myteamspeak.proto.LoginData LoginData = 1; + bytes key = 2; +} +message EncryptionKeyResult { + bool success = 1; + .com.teamspeak.myteamspeak.proto.ErrorCommon errorcode = 2; + string error = 3; +} +message LoginStatus { + .com.teamspeak.myteamspeak.proto.ErrorCommon error = 1; + string uuid = 2; +} +message Permissions { + repeated string permission = 1; +} +message UserData { + string description = 1; +} +message AuthTokenRequest { + string email = 1; + string password = 2; +} +message AuthTokenReply { + string auth_token = 1; + .com.teamspeak.myteamspeak.proto.ErrorCommon error = 3; +} +message AuthTokenLogin { + string auth_token = 1; + string device_description = 2; +} +message RenewalTokenLogin { + string renewal_token = 1; + string auth_token = 2; +} +message AuthToken { + string id = 1; + string mytsid = 2; +} +message RenewalToken { + string token = 1; + .com.teamspeak.myteamspeak.proto.AuthToken auth_token = 2; +} +enum AddonDevStatus { + UNKNOWN_ADDONDEVSTATUS = 0; + REQUESTED = 1; + CONFIRMATION_PENDING = 2; + CONFIRMED = 5; + APPROVED = 3; + REJECTED = 4; + BANNED = 6; +} +enum LoginOrigin { + CLIENT = 0; + WEBSITE = 1; +} +enum ErrorCommon { + UNKNOWN_ERRORCOMMON = 0; + ERROR_FE_UNKNOWN_METHOD = 100; + ERROR_FE_UNKNOWN_DATA = 101; + ERROR_SESSION_OK = 102; + ERROR_SESSION_EXPIRED = 103; + ERROR_SESSION_SERVER_OFFLINE = 104; + ERROR_LOGIN_OK = 200; + ERROR_LOGIN_OFFLINE = 201; + ERROR_LOGIN_FAILED = 202; + ERROR_LOGIN_EMAIL_PENDING = 203; + ERROR_SESSION_DELETED_OK = 204; + ERROR_PUSH_TOKEN_CREATION = 205; + ERROR_AUTH_TOKEN_INVALID = 206; + ERRIR_RENEWAL_TOKEN_INVALID = 207; +} + +// file myteamspeak_integration_static_info.proto +package com.teamspeak.myteamspeak.proto.integration; +import "myteamspeak_integration_common.proto"; +message StaticIntegrationResponseInformation { + .com.teamspeak.myteamspeak.proto.integration.ResponseType type = 1; + string response_type_name = 2; + repeated string possible_values = 3; +} +message StaticIntegrationInformation { + string integration_name = 1; + string integration_icon = 2; + .com.teamspeak.myteamspeak.proto.integration.IntegrationType type = 3; + uint32 version = 4; + repeated .com.teamspeak.myteamspeak.proto.integration.StaticIntegrationResponseInformation available_responses = 5; +} +message StaticIntegrationInformationList { + repeated .com.teamspeak.myteamspeak.proto.integration.StaticIntegrationInformation static_integrations = 1; +} +enum ResponseType { + invalid = 0; + SUBSCRIPTION = 1; + RELATIONSHIP = 2; + TEST_RESPONSE = 100; + ANOTHER_TEST_RESPONSE = 101; +} + +// file myteamspeak_login.proto +package com.teamspeak.myteamspeak.proto.login; +import "myteamspeak_common.proto"; +message requestExpireTime { + string uuid = 1; +} +message replyExpireTime { + int64 purge = 1; +} +message AuthTokenListRequest { + string email = 1; + string password = 2; +} +message AuthTokenListReply { + repeated .com.teamspeak.myteamspeak.proto.login.AuthTokenListReply.Entry entries = 1; + message Entry { + uint64 creation = 1; + string device_description = 2; + string id = 3; + } +} +message AuthTokenExpireRequest { + string email = 1; + string password = 2; + repeated string id = 3; +} +message AuthTokenExpireReply { + bool success = 1; + string error_msg = 2; +} +message AccountStatusInfo { + string email = 1; + string reason = 2; +} +message AccountStatusReply { + bool success = 1; + string error_msg = 2; +} +message AccountEmail { + string email = 1; +} +message SuspensionReasonForUserReply { + string client_id = 1; + repeated .com.teamspeak.myteamspeak.proto.login.SuspensionReasonForUserReply.Reason reasons = 2; + message Reason { + string email = 1; + int64 timestamp = 2; + string reason = 3; + } +} +service LoginService { + rpc session(.com.teamspeak.myteamspeak.proto.Session) returns (.com.teamspeak.myteamspeak.proto.LoginStatus); + rpc deleteSession(.com.teamspeak.myteamspeak.proto.Session) returns (.com.teamspeak.myteamspeak.proto.LoginStatus); + rpc login(.com.teamspeak.myteamspeak.proto.LoginData) returns (.com.teamspeak.myteamspeak.proto.LoginSession); + rpc requestAuthToken(.com.teamspeak.myteamspeak.proto.AuthTokenRequest) returns (.com.teamspeak.myteamspeak.proto.AuthTokenReply); + rpc loginWithAuthToken(.com.teamspeak.myteamspeak.proto.AuthTokenLogin) returns (.com.teamspeak.myteamspeak.proto.LoginSession); + rpc loginWithRenewalToken(.com.teamspeak.myteamspeak.proto.RenewalTokenLogin) returns (.com.teamspeak.myteamspeak.proto.LoginSession); + rpc getAuthTokenList(.com.teamspeak.myteamspeak.proto.login.AuthTokenListRequest) returns (.com.teamspeak.myteamspeak.proto.login.AuthTokenListReply); + rpc expireAuthTokenAccess(.com.teamspeak.myteamspeak.proto.login.AuthTokenExpireRequest) returns (.com.teamspeak.myteamspeak.proto.login.AuthTokenExpireReply); + rpc deleteAccount(.com.teamspeak.myteamspeak.proto.login.AccountStatusInfo) returns (.com.teamspeak.myteamspeak.proto.login.AccountStatusReply); + rpc suspendAccount(.com.teamspeak.myteamspeak.proto.login.AccountStatusInfo) returns (.com.teamspeak.myteamspeak.proto.login.AccountStatusReply); + rpc reactivateAccount(.com.teamspeak.myteamspeak.proto.login.AccountStatusInfo) returns (.com.teamspeak.myteamspeak.proto.login.AccountStatusReply); + rpc listSuspensionReasonForUser(.com.teamspeak.myteamspeak.proto.login.AccountEmail) returns (.com.teamspeak.myteamspeak.proto.login.SuspensionReasonForUserReply); +} + +// file myteamspeak_user.proto +package com.teamspeak.myteamspeak.proto.user; +import "google/protobuf/any.proto"; +import "myteamspeak_avatar_common.proto"; +message BackupKeyData { + .com.teamspeak.myteamspeak.proto.user.ReturnCode returncode = 1; + bytes backupkey = 2; +} +message UserData { + .com.teamspeak.myteamspeak.proto.user.UserLogin login = 1; + string username = 2; + bytes key = 3; + bytes backupkey = 4; + .com.teamspeak.myteamspeak.proto.user.UserImg image = 5; + .com.teamspeak.myteamspeak.proto.user.UserLogin login_old = 6; + bool force_key_overwrite = 8; + bool delete_mytsid_data = 9; + string session = 10; +} +message UserLogin { + string email = 1; + string password = 2; +} +message UserImg { +} +message Session { + string session = 1; +} +message BadgeCode { + string code = 1; + string session = 2; +} +message UserBadge { + string uuid = 1; + int64 received = 2; +} +message SignedUserBadge { + .com.teamspeak.myteamspeak.proto.user.UserBadge badge = 1; + bytes sign = 2; + uint64 sign_timestamp = 3; +} +message UserBadgesSignedResponse { + .com.teamspeak.myteamspeak.proto.user.UserBadgesSignedList list = 1; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error_code = 2; +} +message UserBadgesSignedList { + repeated .com.teamspeak.myteamspeak.proto.user.SignedUserBadge badges = 1; +} +message UserBadgesList { + repeated .com.teamspeak.myteamspeak.proto.user.UserBadge badges = 1; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error_code = 2; +} +message RedeemBadgeCodeResponse { + bool success = 1; + string error = 2; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error_code = 3; + string badge_uuid = 4; +} +message ReturnCode { + bool success = 1; + string error = 2; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error_code = 3; + string client_id = 4; +} +message NewUserPublicKeyData { + .com.teamspeak.myteamspeak.proto.user.Session session = 1; + bytes user_public_key = 2; + bytes proof = 3; + bytes encrypted_user_private_key = 4; +} +message NewUserPublicKeyDataResponse { + uint64 account_creation_time = 1; + bytes myts_id = 2; + bytes public_signature = 3; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error = 4; +} +message AvatarRequestID { + .google.protobuf.Any key = 1; + message MYTSKey { + bytes id = 1; + } + message KIDKey { + string id = 1; + } +} +message RequestContactsAvatarInfoRequest { + string session = 1; + repeated .com.teamspeak.myteamspeak.proto.user.AvatarRequestID id = 2; +} +message RequestContactsAvatarInfoResponse { + repeated .com.teamspeak.myteamspeak.proto.user.RequestContactsAvatarInfoResponse.AvatarInfoMap data = 1; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error_code = 2; + message AvatarInfoMap { + .com.teamspeak.myteamspeak.proto.user.AvatarRequestID id = 1; + .com.teamspeak.myteamspeak.proto.AvatarData info = 2; + } +} +message SpawnVoiceServerLocationRequest { + string session = 1; + string ip_address = 2; +} +message SpawnVoiceServerLocationResponse { + .com.teamspeak.myteamspeak.proto.user.ReturnCode return_code = 1; + repeated .com.teamspeak.myteamspeak.proto.user.SpawnVoiceServerLocation possible_location = 2; +} +message SpawnVoiceServerLocation { + repeated string address = 1; + string status = 2; + int64 connected_clients = 3; + int64 used_slots = 4; + int64 used_channels = 5; + int64 used_server = 6; +} +message SpawnedVoiceServerListResponse { + .com.teamspeak.myteamspeak.proto.user.ReturnCode return_code = 2; + repeated .com.teamspeak.myteamspeak.proto.user.VoiceServerPayload payload = 3; +} +message SpawnedVoiceServer { + string server_uid = 1; + repeated string address = 2; +} +message VoiceServerRequest { + string session = 1; + .com.teamspeak.myteamspeak.proto.user.VoiceServerPayload payload = 2; +} +message VoiceServerResponse { + .com.teamspeak.myteamspeak.proto.user.ReturnCode return_code = 1; + .com.teamspeak.myteamspeak.proto.user.VoiceServerPayload payload = 2; +} +message VoiceServerOperation { + string operation = 1; + .com.teamspeak.myteamspeak.proto.user.VoiceServerPayload payload = 2; +} +message VoiceServerPayload { + string virtualserver_uuid = 1; + string virtualserver_name = 2; + string virtualserver_welcomemessage = 3; + uint32 virtualserver_maxclients = 4; + string virtualserver_password = 5; + string virtualserver_hostname = 6; + uint32 virtualserver_port = 7; + string virtualserver_token = 8; + string virtualserver_status = 9; + uint32 virtualserver_uptime = 10; + uint32 virtualserver_clientsonline = 11; + uint32 virtualserver_total_ping = 12; + string virtualserver_location = 13; + string virtualserver_unique_identifier = 14; +} +message RequestUploadAvatarRequest { + string session = 1; + repeated .com.teamspeak.myteamspeak.proto.user.RequestUploadAvatarRequest.AvatarImageMap image_names = 2; + message AvatarImageMap { + .com.teamspeak.myteamspeak.proto.AvatarState state = 1; + string name = 2; + } +} +message RequestUploadAvatarResponse { + .com.teamspeak.myteamspeak.proto.AvatarError error = 1; +} +message RequestDeleteAvatarInfoRequest { + string session = 1; + repeated .com.teamspeak.myteamspeak.proto.AvatarState images = 2; +} +message AvatarSignedUrlRequest { + string session = 1; + repeated string file_names = 2; +} +message AvatarSignedUrlResponse { + .com.teamspeak.myteamspeak.proto.user.ReturnCode return_code = 1; + repeated .com.teamspeak.myteamspeak.proto.user.AvatarSignedUrlResponse.SignedUrl signed_urls = 2; + message SignedUrl { + string file_name = 1; + string signed_url = 2; + } +} +message EmailChange { + string email = 1; + string session = 2; + string validationKey = 3; + bytes key = 4; + string newPw = 5; + string currentPw = 6; +} +message ResetAccountRequest { + string session = 1; + bytes key = 2; + bytes backup_key = 3; + .com.teamspeak.myteamspeak.proto.user.NewUserPublicKeyData myts_id = 4; +} +message UpdateUserDescriptionRequest { + string session = 1; + string description = 2; +} +message UploadFileRequest { + string session = 1; + string user_id = 2; + string room_id = 3; +} +message DownloadFileRequest { + string session = 1; + string user_id = 2; + string room_id = 3; + string file_id = 4; +} +message FileInfo { + bool success = 1; + .com.teamspeak.myteamspeak.proto.user.ErrorReturnCode error = 2; + string user_id = 3; + string room_id = 4; + string file_id = 5; + string signed_url = 6; +} +message OtpSecretResponse { + string otp_secret = 1; + bytes otp_qr_code_image = 2; + string otp_qr_code_mime_type = 3; + .com.teamspeak.myteamspeak.proto.user.ReturnCode return_code = 4; +} +message UpdateTwoFactorAuthTypeRequest { + string session = 1; + .com.teamspeak.myteamspeak.proto.user.AuthType auth_type = 2; +} +message TwoFactorAuthTypeResponse { + .com.teamspeak.myteamspeak.proto.user.AuthType auth_type = 1; + .com.teamspeak.myteamspeak.proto.user.ReturnCode return_code = 2; +} +message RegisterFirebasePush { + string session = 1; + string deviceToken = 2; +} +enum AuthType { + NONE = 0; + OTP = 1; +} +enum ErrorReturnCode { + UNKNOWN_ERRORRETURNCODE = 0; + ERROR_MISSING_REQUIRED_FIELD = 100; + ERROR_EMAIL_ALREADY_TAKEN = 101; + ERROR_UUID_COLLISION = 102; + ERROR_DATABASE = 103; + ERROR_LOGIN_FAILED = 104; + ERROR_EMPTY_KEY = 105; + ERROR_EMAIL_INVALID = 106; + ERROR_NOT_ALLOWED = 107; + ERROR_USERNAME_ALREADY_TAKEN = 108; + ERROR_SESSION_EXPIRED = 109; + ERROR_CODE_INVALID = 110; + ERROR_USERNAME_INVALID = 111; + ERROR_BADGE_ALREADY_PRESENT = 112; + ERROR_KEY_ALREADY_PRESENT = 113; + ERROR_INVALID_KEY = 114; + ERROR_INVALID_PROOF = 115; + ERROR_INTERNAL = 116; + ERROR_QUOTA_EXCEEDED = 117; + ERROR_CHANGE_EMAIL_FAILED = 118; + ERROR_CHANGE_EMAIL_FAILED_MISSING_FIELD = 119; + ERROR_CHANGE_EMAIL_FAILED_FUNCTIONAL_EMAIL_FORMAT = 120; + ERROR_CHANGE_EMAIL_FAILED_FUNCTIONAL_EQUAL_EMAIL = 121; + ERROR_CHANGE_EMAIL_FAILED_FUNCTIONAL_TARGET_EXISTS = 122; + ERROR_CHANGE_EMAIL_FAILED_FUNCTIONAL_ALREADY_PENDING = 123; + ERROR_VOICE_SERVER_START_FAILED = 127; + ERROR_VOICE_SERVER_STOP_FAILED = 128; + ERROR_VOICE_SERVER_READ_FAILED = 129; + ERROR_VOICE_SERVER_TOO_MANY_REQUESTS = 130; + ERROR_VOICE_SERVER_CREATE_FAILED = 131; + ERROR_VOICE_SERVER_DELETE_FAILED = 132; +} +service UserAccountService { + rpc addUser(.com.teamspeak.myteamspeak.proto.user.UserData) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc updateUser(.com.teamspeak.myteamspeak.proto.user.UserData) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc getBackupKey(.com.teamspeak.myteamspeak.proto.user.UserLogin) returns (.com.teamspeak.myteamspeak.proto.user.BackupKeyData); + rpc getBadges(.com.teamspeak.myteamspeak.proto.user.Session) returns (.com.teamspeak.myteamspeak.proto.user.UserBadgesList); + rpc redeemBadgeCode(.com.teamspeak.myteamspeak.proto.user.BadgeCode) returns (.com.teamspeak.myteamspeak.proto.user.RedeemBadgeCodeResponse); + rpc setNewUserPublicKey(.com.teamspeak.myteamspeak.proto.user.NewUserPublicKeyData) returns (.com.teamspeak.myteamspeak.proto.user.NewUserPublicKeyDataResponse); + rpc requestContactsAvatar(.com.teamspeak.myteamspeak.proto.user.RequestContactsAvatarInfoRequest) returns (.com.teamspeak.myteamspeak.proto.user.RequestContactsAvatarInfoResponse); + rpc getSignedBadges(.com.teamspeak.myteamspeak.proto.user.Session) returns (.com.teamspeak.myteamspeak.proto.user.UserBadgesSignedResponse); + rpc requestVoiceServer(.com.teamspeak.myteamspeak.proto.user.VoiceServerRequest) returns (.com.teamspeak.myteamspeak.proto.user.VoiceServerResponse); + rpc requestVoiceServerLocation(.com.teamspeak.myteamspeak.proto.user.SpawnVoiceServerLocationRequest) returns (.com.teamspeak.myteamspeak.proto.user.SpawnVoiceServerLocationResponse); + rpc deleteSpawnedVoiceServer(.com.teamspeak.myteamspeak.proto.user.VoiceServerRequest) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc listSpawnedVoiceServer(.com.teamspeak.myteamspeak.proto.user.Session) returns (.com.teamspeak.myteamspeak.proto.user.SpawnedVoiceServerListResponse); + rpc startVoiceServer(.com.teamspeak.myteamspeak.proto.user.VoiceServerRequest) returns (.com.teamspeak.myteamspeak.proto.user.VoiceServerResponse); + rpc stopVoiceServer(.com.teamspeak.myteamspeak.proto.user.VoiceServerRequest) returns (.com.teamspeak.myteamspeak.proto.user.VoiceServerResponse); + rpc readVoiceServerStatus(.com.teamspeak.myteamspeak.proto.user.VoiceServerRequest) returns (.com.teamspeak.myteamspeak.proto.user.VoiceServerResponse); + rpc requestAvatarSignedUrl(.com.teamspeak.myteamspeak.proto.user.AvatarSignedUrlRequest) returns (.com.teamspeak.myteamspeak.proto.user.AvatarSignedUrlResponse); + rpc requestUploadAvatar(.com.teamspeak.myteamspeak.proto.user.RequestUploadAvatarRequest) returns (.com.teamspeak.myteamspeak.proto.user.RequestUploadAvatarResponse); + rpc requestDeleteAvatar(.com.teamspeak.myteamspeak.proto.user.RequestDeleteAvatarInfoRequest) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc changeEmail(.com.teamspeak.myteamspeak.proto.user.EmailChange) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc resetAccount(.com.teamspeak.myteamspeak.proto.user.ResetAccountRequest) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc updateUserDescription(.com.teamspeak.myteamspeak.proto.user.UpdateUserDescriptionRequest) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc requestUploadFile(.com.teamspeak.myteamspeak.proto.user.UploadFileRequest) returns (.com.teamspeak.myteamspeak.proto.user.FileInfo); + rpc requestDownloadFile(.com.teamspeak.myteamspeak.proto.user.DownloadFileRequest) returns (.com.teamspeak.myteamspeak.proto.user.FileInfo); + rpc generateOtpSetupSecret(.com.teamspeak.myteamspeak.proto.user.Session) returns (.com.teamspeak.myteamspeak.proto.user.OtpSecretResponse); + rpc updateTwoFactorAuthType(.com.teamspeak.myteamspeak.proto.user.UpdateTwoFactorAuthTypeRequest) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); + rpc getTwoFactorAuthType(.com.teamspeak.myteamspeak.proto.user.Session) returns (.com.teamspeak.myteamspeak.proto.user.TwoFactorAuthTypeResponse); + rpc registerFirebasePush(.com.teamspeak.myteamspeak.proto.user.RegisterFirebasePush) returns (.com.teamspeak.myteamspeak.proto.user.ReturnCode); +} + +// file synchronization_common.proto +package com.teamspeak.myteamspeak.proto.synchronization; +message Sync_Request_ItemClasses { + string session = 1; + repeated .com.teamspeak.myteamspeak.proto.synchronization.Sync_ItemClasses_Data classes = 2; + string globalversion = 3; + .com.teamspeak.myteamspeak.proto.synchronization.Sync_Version sync_version = 4; +} +message Sync_Reply_ItemClasses { + .com.teamspeak.myteamspeak.proto.synchronization.SyncStatus status = 1; + repeated .com.teamspeak.myteamspeak.proto.synchronization.Sync_ItemClasses_Data classes = 2; + string globalversion = 3; +} +message Sync_ItemClasses_Data { + .com.teamspeak.myteamspeak.proto.synchronization.Item_Class itemclass = 1; + string version = 2; + repeated .com.teamspeak.myteamspeak.proto.synchronization.Sync_ItemClasses_Data_Detail detail = 3; + bool not_in_db = 4; +} +message Sync_ItemClasses_Data_Detail { + string item_uuid = 1; + string item_version = 2; + bytes item_blob = 3; + bool item_delte_on_server = 4; +} +enum SyncStatus { + UNKNOWN_SYNCSTATUS = 0; + ERROR_FE_UNKNOWN_METHOD = 100; + ERROR_FE_UNKNOWN_DATA = 101; + ERROR_SESSION_EXPIRED = 102; + ERROR_CRITICAL = 103; + IN_SYNC = 300; + NOT_IN_SYNC = 301; + NOT_IN_DB = 302; + COLLISION = 303; + OVERLIMIT = 304; +} +enum Item_Class { + BOOKMARK = 0; + IDENTITY = 1; + WHISPER_LIST = 2; + HOTKEY_PROFILE = 3; + ADD_ON = 4; + CONFIG = 5; + ITEM_FOLDER = 6; + ROOM_CATEGORY = 7; +} +enum Sync_Version { + DEFAULT = 0; + V1_1 = 1; +} + +// file myteamspeak_messenger_connector_client.proto +package com.teamspeak.myteamspeak.proto.messengerconnector; +import "google/protobuf/any.proto"; +message RequestCreateMessengerAccountRequest { + string session = 1; +} +message RequestCreateMessengerAccountReply { + .com.teamspeak.myteamspeak.proto.messengerconnector.MessengerConnectorReturnCodesClient return_code = 1; +} +message InformContactsAvatarHasChangedRequest { + string client_id = 1; +} +message UsernameHasChangedRequest { + string client_id = 1; + string username = 2; +} +message GenericMessageWrapper { + .google.protobuf.Any message = 1; +} +enum MessengerConnectorReturnCodesClient { + ERROR_MESSENGER_CLIENT_CODE_INVALID = 0; + SUCCESS_ACCOUNT_CREATED = 1; + ERROR_ACCOUNT_CREATION_FAILED = 100; + ERROR_ACCOUNT_ALREADY_EXISTS = 101; +} +service MessengerConnectorClientService { + rpc requestCreateMessengerAccount(.com.teamspeak.myteamspeak.proto.messengerconnector.RequestCreateMessengerAccountRequest) returns (.com.teamspeak.myteamspeak.proto.messengerconnector.RequestCreateMessengerAccountReply); +} + +// file myteamspeak_avatar_common.proto +package com.teamspeak.myteamspeak.proto; +import "google/protobuf/any.proto"; +message AvatarData { + .com.teamspeak.myteamspeak.proto.AvatarInfo info = 1; + uint64 timestamp = 2; + bytes sign = 3; + .google.protobuf.Any optional = 4; +} +message OptionalAvatarDataContactInfo { + bytes mytsid = 1; + bytes user_cert = 2; +} +message AvatarInfo { + repeated .com.teamspeak.myteamspeak.proto.AvatarInfo.AvatarMap map = 1; + message AvatarMap { + .com.teamspeak.myteamspeak.proto.AvatarState state = 1; + string name = 2; + } +} +enum AvatarError { + AVATAR_ERROR_INVALID = 0; + AVATAR_ERROR_OK = 1; + AVATAR_ERROR_CRITICAL = 2; + AVATAR_SESSION_INVALID = 3; +} +enum AvatarState { + AVATAR_STATE_INVALID = 0; + AVATAR_STATE_DND = 1; + AVATAR_STATE_ONLINE = 2; + AVATAR_STATE_AWAY = 3; + AVATAR_STATE_OFFLINE = 4; +} + +// file myteamspeak_push.proto +package com.teamspeak.myteamspeak.proto.push; +import "google/protobuf/any.proto"; +message IntermediateData { + string uuid = 1; + .google.protobuf.Any payload = 2; +} +message PushNotification { + .google.protobuf.Any payload = 1; +} +message SimpleNotification { + .com.teamspeak.myteamspeak.proto.push.SimpleNotificationType type = 1; + bool relogin_neccessary = 2; +} +message ContactAvatarChanged { + repeated string kid = 1; +} +message TsChatAvatarChanged { + repeated bytes mytsid = 1; +} +message AuthTokenUsed { + string id_hash = 1; +} +enum SimpleNotificationType { + SIMPLE_NOTIFICATION_TYPE_INVALID = 0; + SIMPLE_NOTIFICATION_SYNC_DATA_CHANGED = 1; + SIMPLE_NOTIFICATION_SESSION_EXPIRED = 2; + SIMPLE_NOTIFICATION_CONTACT_HOMEBASE_CHANGED = 3; +} + +// file myteamspeak_tschat.proto +package com.teamspeak.myteamspeak.proto.tschat; +message GroupSessionRequest { + .com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser user = 1; + repeated .com.teamspeak.myteamspeak.proto.tschat.GroupSession group_sessions = 2; +} +message GroupSessionResponse { + .com.teamspeak.myteamspeak.proto.tschat.ErrorHandling error = 1; + repeated .com.teamspeak.myteamspeak.proto.tschat.GroupSession group_sessions = 2; +} +message GroupSession { + string room_id = 1; + string shared_session_id = 2; + string cipher_text = 3; +} +message AuthenticatedUser { + string session = 1; + string matrix_id = 2; +} +message Contact { + string ts_chat_identifier = 1; + string note = 2; + string matrix_id = 3; + repeated .com.teamspeak.myteamspeak.proto.tschat.MxidHistory matrix_id_history = 4; +} +message MxidHistory { + string matrix_id = 1; + uint64 initiation_timestamp = 2; +} +message ContactList { + repeated .com.teamspeak.myteamspeak.proto.tschat.Contact contacts = 1; + .com.teamspeak.myteamspeak.proto.tschat.ErrorHandling error_handling = 2; +} +message ChatCredentials { + string domain = 1; + string username = 2; + string password = 3; +} +message CreateAccountResponse { + .com.teamspeak.myteamspeak.proto.tschat.ErrorHandling error_handling = 1; + .com.teamspeak.myteamspeak.proto.tschat.ChatCredentials chat_credentials = 2; +} +message MoveRequest { + .com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser user = 1; + string new_matrix_id = 2; +} +message ContactRequest { + .com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser user = 1; + .com.teamspeak.myteamspeak.proto.tschat.Contact contact = 2; +} +message ContactRequestList { + .com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser user = 1; + repeated .com.teamspeak.myteamspeak.proto.tschat.Contact contacts = 2; +} +message IdentifierRequest { + .com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser user = 1; + .com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierMapping ts_chat_identifier_mapping = 2; +} +message TschatIdentifierMapping { + string ts_chat_identifier = 1; + string matrix_id = 2; + bool primary = 3; +} +message TschatIdentifierList { + repeated .com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierMapping ts_chat_identifier_mapping = 1; + .com.teamspeak.myteamspeak.proto.tschat.ErrorHandling error_handling = 2; +} +message TschatIdentifierTagList { + repeated string tag = 1; +} +message MatrixIdentifierToken { + bytes signature = 1; + bytes sign_certificate = 2; + uint64 timestamp = 3; + .com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierTagList tags = 4; +} +message SignedAllowedIdentifier { + .com.teamspeak.myteamspeak.proto.tschat.MatrixIdentifierToken token = 1; + .com.teamspeak.myteamspeak.proto.tschat.ErrorHandling error = 2; +} +message ErrorHandling { + .com.teamspeak.myteamspeak.proto.tschat.ChatRequestReturnCode return_code = 1; + string message = 2; +} +enum ChatRequestReturnCode { + CHATREQUESTRETURNCODE_ERROR_UNKNOWN = 0; + CHATREQUESTRETURNCODE_SUCCESS = 1; + CHATREQUESTRETURNCODE_CONNECTION = 2; + CHATREQUESTRETURNCODE_INTERNAL = 3; + CHATREQUESTRETURNCODE_REQUEST = 4; + CHATREQUESTRETURNCODE_SESSION_EXPIRED = 5; +} +service ChatRequests { + rpc createAccount(.com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser) returns (.com.teamspeak.myteamspeak.proto.tschat.CreateAccountResponse); + rpc moveHome(.com.teamspeak.myteamspeak.proto.tschat.MoveRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc getContactList(.com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser) returns (.com.teamspeak.myteamspeak.proto.tschat.ContactList); + rpc updateContact(.com.teamspeak.myteamspeak.proto.tschat.ContactRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc updateContactList(.com.teamspeak.myteamspeak.proto.tschat.ContactRequestList) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc removeContact(.com.teamspeak.myteamspeak.proto.tschat.ContactRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc setPrimaryIdentifier(.com.teamspeak.myteamspeak.proto.tschat.IdentifierRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierList); + rpc addIdentifier(.com.teamspeak.myteamspeak.proto.tschat.IdentifierRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierList); + rpc removeIdentifier(.com.teamspeak.myteamspeak.proto.tschat.IdentifierRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierList); + rpc getActiveIdentifierList(.com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser) returns (.com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierList); + rpc getAllowedIdentifierList(.com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser) returns (.com.teamspeak.myteamspeak.proto.tschat.TschatIdentifierList); + rpc getGroupSessionData(.com.teamspeak.myteamspeak.proto.tschat.GroupSessionRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.GroupSessionResponse); + rpc uploadGroupSessionData(.com.teamspeak.myteamspeak.proto.tschat.GroupSessionRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc clearAllGroupSessions(.com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc clearGroupSessions(.com.teamspeak.myteamspeak.proto.tschat.GroupSessionRequest) returns (.com.teamspeak.myteamspeak.proto.tschat.ErrorHandling); + rpc requestSignedAllowedIdentifierList(.com.teamspeak.myteamspeak.proto.tschat.AuthenticatedUser) returns (.com.teamspeak.myteamspeak.proto.tschat.SignedAllowedIdentifier); +} + +// file Sync_Serializing.proto +package com.teamspeak.sync.proto; +import "synchronization_common.proto"; +message Bookmark_Data { + string name = 1; + string address = 2; + uint32 port = 3; + string nickname = 4; + string phonetic_nickname = 5; + string identity = 6; + string server_password = 7; + string default_channel = 8; + string default_channel_password = 9; + uint64 default_channel_id = 10; + string capture_profile = 11; + string playback_profile = 12; + string hotkey_profile = 13; + bool autoconnect = 14; + string server_uid = 15; + string server_icon = 16; + string sound_pack = 17; + bool show_server_query_clients = 18; + .com.teamspeak.sync.proto.Bookmark_Data.SubscriptionMode subscription_mode = 19; + repeated uint64 subscribed_channel_ids = 20; + repeated .com.teamspeak.sync.proto.Bookmark_Data.ChannelPassword channel_passwords = 21; + bool send_mytsid_on_server = 22; + bool announce_to_chat = 23; + string resolved_server_nickname = 24; + message ChannelPassword { + uint64 channel_id = 1; + string password = 2; + } + enum SubscriptionMode { + Undefined = 0; + All = 1; + Current = 2; + } +} +message Identity_Data { + string unique_identity = 1; + string id_name = 2; + string nickname = 3; + string phonetic_nickname = 4; + bool is_default = 5; +} +message Whisper_list_data { + string uuid = 1; + string name = 2; + .com.teamspeak.sync.proto.Whisper_list_data.WhisperListType type = 3; + int32 groupType = 4; + int32 groupTargetMode = 5; + uint64 targetGroupID = 6; + string targetGroupName = 7; + string targetServerUid = 8; + string targetServerName = 9; + repeated .com.teamspeak.sync.proto.Whisper_list_data.WhisperEntry whisperListEntries = 10; + message WhisperEntry { + .com.teamspeak.sync.proto.Whisper_list_data.WhisperEntry.WhisperEntryType type = 1; + string name = 2; + string uid = 3; + enum WhisperEntryType { + WET_INVALID = 0; + WET_CLIENT = 1; + WET_CHANNEL = 2; + } + } + enum WhisperListType { + WLT_INVALID = 0; + WLT_LIST = 1; + WLT_PREDEFINED = 2; + } +} +message HotkeyProfile_data { + string uuid = 1; + string name = 2; + repeated .com.teamspeak.sync.proto.HotkeyProfile_data.Hotkey hotkeys = 3; + bool is_default = 4; + message Hotkey { + .com.teamspeak.sync.proto.HotkeyProfile_data.Hotkey.OS os = 3; + int32 event_type = 4; + string event_uuid = 5; + string event_mode = 6; + repeated .com.teamspeak.sync.proto.HotkeyProfile_data.Hotkey.KeyDef keys = 7; + uint32 action_type = 8; + string action_uuid = 9; + bool action_discard = 10; + string action_mode = 11; + string action_flag = 12; + string action_description = 13; + string action_plugin = 14; + string action_away_message = 15; + string action_soundpack = 16; + string action_plugincommand = 17; + string action_plugindescription = 18; + string action_deviceuid = 19; + string action_devicename = 20; + string action_target = 21; + bool action_auto = 22; + string action_profiles = 23; + bool action_active_tab = 24; + message KeyDef { + string identifier = 1; + string key = 2; + } + enum OS { + Windows = 0; + OSX = 1; + Linux = 2; + } + } +} +message Add_on_data { + string uuid = 1; + bool enabled = 2; +} +message Chat_Credentials { + string domain = 1; + string username = 2; + string password = 3; +} +message Config_data { + int32 default_subscribe_mode = 1; + repeated string user_badges = 2; + bool show_user_badges = 3; + bytes global_presence_token = 4; + .com.teamspeak.sync.proto.Chat_Credentials tschat_credentials = 5; + repeated .com.teamspeak.sync.proto.TSCHAT_DE_Data tschat_de_data = 7; +} +message Item_Folder { + string folder_name = 1; + .com.teamspeak.myteamspeak.proto.synchronization.Item_Class folder_type = 2; +} +message Room_Category { + string name = 1; + repeated string ids = 2; +} +message Item_Data { + // oneof item_content + string item_uuid = 2; + string sync_version_uuid = 3; + .com.teamspeak.sync.proto.Item_Data.Manipulation_Flag manipulated = 4; + string last_known_version = 5; + uint32 item_type = 6; + string parent = 7; + string sort_order = 8; + uint64 timestamp = 9; + .com.teamspeak.sync.proto.Bookmark_Data bookmark = 16; [oneof 0] + .com.teamspeak.sync.proto.Identity_Data identity = 17; [oneof 0] + .com.teamspeak.sync.proto.Whisper_list_data whisperlist = 18; [oneof 0] + .com.teamspeak.sync.proto.HotkeyProfile_data hotkeyprofile = 19; [oneof 0] + .com.teamspeak.sync.proto.Add_on_data add_on = 20; [oneof 0] + .com.teamspeak.sync.proto.Config_data config = 21; [oneof 0] + .com.teamspeak.sync.proto.Item_Folder item_folder = 22; [oneof 0] + .com.teamspeak.sync.proto.Room_Category room_category = 23; [oneof 0] + enum Manipulation_Flag { + ITEM_ADDED = 0; + ITEM_CHANGED = 1; + ITEM_DELETED = 2; + ITEM_NOT_MANIPULATED = 3; + } +} +message TSCHAT_DE_Data { + string url = 1; + string identity = 2; + string username = 3; + string server_id = 4; + string address = 5; + string port = 6; + string name = 7; +} + +// file Account_Serializing.proto +package com.teamspeak.account.proto; +import "myteamspeak_common.proto"; +import "myteamspeak_integration_common.proto"; +import "myteamspeak_avatar_common.proto"; +import "myteamspeak_tschat.proto"; +message Account_Session { + string id = 1; +} +message Account_Data { + string email = 1; + string password = 2; + string uuid = 3; + string user_name = 4; + bytes encryption_password = 5; + string global_items_uuid = 10; + string class_bookmark_version_uuid = 11; + string class_identity_version_uuid = 12; + string class_add_on_version_uuid = 13; + string class_config_version_uuid = 14; + string class_hotkey_profile_version_uuid = 15; + string class_whisper_list_version_uuid = 16; + string class_item_folder_version_uuid = 17; + string class_item_room_category_version_uuid = 18; + .com.teamspeak.account.proto.Account_Session session = 50; + repeated .com.teamspeak.myteamspeak.proto.Limits limits = 51; + bytes key = 52; + bytes backup_ed_key = 53; + .com.teamspeak.myteamspeak.proto.MyTeamSpeakIdData myteamspeakid_data = 54; + repeated .com.teamspeak.account.proto.Account_Data.IntegrationCache integration_cache = 55; + .com.teamspeak.myteamspeak.proto.MyTSUserCertificate myteamspeakid_user_certificate = 56; + .com.teamspeak.myteamspeak.proto.AvatarData avatar_data = 57; + string push_token = 58; + .com.teamspeak.account.proto.Permissions permissions = 59; + .com.teamspeak.myteamspeak.proto.tschat.MatrixIdentifierToken identifier_token = 61; + .com.teamspeak.account.proto.Account_Data.AuthTokenCache auth_token_cache = 62; + string firebase_device_token = 63; + message IntegrationCache { + repeated string user_integration_id = 1; + .com.teamspeak.myteamspeak.proto.integration.IntegrationResponseList integration_cache = 2; + } + message AuthTokenCache { + string auth_token = 1; + string renewal_token = 2; + } +} +message TS_Sync_Account { + string passphrase = 1; +} +message Permissions { + repeated string permission = 1; +} +message MyTSID_Verification_Token { + bytes verification_sign = 1; + uint64 sign_timestamp = 2; + bytes user_pub_key = 3; + bytes pub_sign = 4; + bytes pub_sign_cert = 5; + uint64 ac_time = 6; + bytes mytsid = 7; +} +message Auth_Token_Package { + uint32 version = 1; + string auth_token = 2; + string encrypted_encryption_password = 3; + string encryption_tag = 4; + string iv = 5; +} + +// file myteamspeak_synchronization.proto +package com.teamspeak.myteamspeak.proto.synchronization; +import "synchronization_common.proto"; +service SynchronizationService { + rpc requestServerItems(.com.teamspeak.myteamspeak.proto.synchronization.Sync_Request_ItemClasses) returns (.com.teamspeak.myteamspeak.proto.synchronization.Sync_Reply_ItemClasses); + rpc synchronizeItems(.com.teamspeak.myteamspeak.proto.synchronization.Sync_Request_ItemClasses) returns (.com.teamspeak.myteamspeak.proto.synchronization.Sync_Reply_ItemClasses); +} + diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 144e9be..f006636 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MyTeamSpeakPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MyTeamSpeakPanel.java new file mode 100644 index 0000000..8fa3ab1 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MyTeamSpeakPanel.java @@ -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 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("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."), 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("Signed in as " + escape(login.username()) + "
" + + escape(login.email()) + ""); + 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 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 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 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 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() ? " " : "" + escape(text) + ""); + label.setForeground(UIManager.getColor(error ? "Actions.Red" : "Label.foreground")); + } + + private static String escape(String s) { + return s.replace("&", "&").replace("<", "<"); + } + + 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 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 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 updated = new ArrayList<>(rows); + updated.set(row, new Row(r.item(), r.present(), Boolean.TRUE.equals(value))); + rows = updated; + fireTableRowsUpdated(row, row); + updateImportButton(); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 0d2aa09..38f538f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -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();