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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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