From a7aa122728b3da5aca52db30aef8541ea1c74fad Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 26 Sep 2026 01:07:17 +0000 Subject: [PATCH] Sign in to myTeamSpeak and import what the account synchronises The myTeamSpeak client protocol and its end-to-end encryption are reverse-engineered (docs/myteamspeak): an HTTP POST of a framed protobuf, a PBKDF2-HMAC-SHA512 login token and account key, an AES-GCM wrapped item key and AES-CTR items with a SHA-512 trailer. Options on desktop and Settings on Android gain a myTeamSpeak page: sign in and out, and the account's bookmarks and identities, each picked for import and marked when already here. The client stays signed in the way the official one does, keeping the derived token and key rather than the password; each read signs in afresh, and a sign-in the server no longer takes signs out. Nothing is written to the account. Items are the same Item_Data as the local TeamSpeak store, so they go through the existing decoder and importer; a bookmark chosen alone brings the identity it connects with, found by UUID or, as some name it, by the identity's name. Co-Authored-By: Claude Opus 5.5 --- .../ts3client/android/SessionController.kt | 26 +- .../ts3client/android/ui/MyTeamSpeakScreen.kt | 221 +++ .../ts3client/android/ui/SettingsScreen.kt | 15 + .../java/com/ts3client/myts/MyTeamSpeak.java | 198 +++ .../com/ts3client/myts/MyTeamSpeakLogin.java | 134 ++ .../java/com/ts3client/myts/MyTsCrypto.java | 136 ++ .../com/ts3client/myts/MyTsException.java | 30 + .../com/ts3client/myts/MyTsTransport.java | 54 + .../{teamspeak => proto}/ProtobufReader.java | 22 +- .../com/ts3client/proto/ProtobufWriter.java | 47 + .../com/ts3client/teamspeak/SyncItem.java | 3 +- .../ts3client/teamspeak/SyncItemDecoder.java | 9 +- .../teamspeak/TeamSpeakImporter.java | 85 +- .../ts3client/myts/MyTeamSpeakLoginTest.java | 65 + .../com/ts3client/myts/MyTeamSpeakTest.java | 112 ++ .../com/ts3client/myts/MyTsCryptoTest.java | 117 ++ .../teamspeak/TeamSpeakImporterTest.java | 35 + ts3-client/docs/myteamspeak/CRYPTO_RE.md | 68 + ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md | 378 +++++ ts3-client/docs/myteamspeak/IMPLEMENTATION.md | 48 + ts3-client/docs/myteamspeak/PROTOCOL.md | 81 ++ .../myteamspeak/myteamspeak.recovered.proto | 1213 +++++++++++++++++ .../main/java/com/ts3client/ui/MainFrame.java | 4 + .../com/ts3client/ui/MyTeamSpeakPanel.java | 352 +++++ .../java/com/ts3client/ui/SettingsDialog.java | 4 +- 25 files changed, 3425 insertions(+), 32 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt create mode 100644 ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeak.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/myts/MyTeamSpeakLogin.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/myts/MyTsCrypto.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/myts/MyTsException.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/myts/MyTsTransport.java rename ts3-client/core/src/main/java/com/ts3client/{teamspeak => proto}/ProtobufReader.java (85%) create mode 100644 ts3-client/core/src/main/java/com/ts3client/proto/ProtobufWriter.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakLoginTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/myts/MyTeamSpeakTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/myts/MyTsCryptoTest.java create mode 100644 ts3-client/docs/myteamspeak/CRYPTO_RE.md create mode 100644 ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md create mode 100644 ts3-client/docs/myteamspeak/IMPLEMENTATION.md create mode 100644 ts3-client/docs/myteamspeak/PROTOCOL.md create mode 100644 ts3-client/docs/myteamspeak/myteamspeak.recovered.proto create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/MyTeamSpeakPanel.java 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();