Sign in to myTeamSpeak and import what the account synchronises

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

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

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

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

View File

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

View File

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

View File

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