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>
22 KiB
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_ImplRTTI string at0x24a0a0, typeinfo at0x11c1088, vtable address point at0x11c0dc0; setup slot+0x18resolves to0xa03e40.- KDF construction helper
0xab74a0loads the literalts3Loginat0x223384. - PBKDF helper
0xab94a0fixesdkLen=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:
loginToken(email, password) -> 48-byte value(base64'd intoLoginData.password), anddecryptItem(item_blob, ...) -> Item_Data protobuf plaintext.
Both goals, including authenticated item framing, are complete.
All primitives are standard (AES-256-GCM, SHA-256, SHA-512, CTR-DRBG — TeamSpeak's static "teamcrypto" lib has nothing else). The unknown is the construction / salt, not the primitives.
The captured account's email, password, login token, keys and session are deliberately left out: the login token and account key sign in and decrypt just as the password does. Sample B below is a login the client computed for an address without an account.
1. Ground-truth samples (all hex unless noted)
1a. Login token (deterministic — verified identical across two separate logins for the same creds)
Wire framing of the request body to POST https://clientapi.myteamspeak.com/authentication:
0x05 "login" <LoginData protobuf>, where LoginData { email=1:string, password=2:string }, and the
password field is base64 text of exactly 48 bytes.
Sample A (server ACCEPTED this login): a real account; omitted. It reproduces byte-for-byte too.
Sample B (server REJECTED — wrong password — but the client still computed & sent the token, so it is a valid input→output pair of the SAME client-side KDF):
- email =
probe.nobody@example.com - password =
wrongpassword1 - token (base64) =
kJLau9cQuYPQi/hi0ey4dPBChwvTMhz2lfAYHWQnNNtaVffCzHHac8mIFVb2FpN4 - token (48 bytes) =
9092dabbd710b983d08bf862d1ecb874f042870bd3321cf695f0181d642734db5a55f7c2cc71da73c9881556f6169378
Determinism ⇒ no random salt/nonce in the token; any salt is fixed or derived from email/password.
1b. LoginSession reply for Sample A (values omitted)
LoginSession { key=1 bytes, session=2 string, limits=3, uuid=4 string, error=5 varint, purge=6 varint, username=8 string, myts_id_data=9 MyTeamSpeakIdData, ... alternative_login_info=16 }
- error (f5) = 200 (ErrorCommon.ERROR_LOGIN_OK)
- session (f2) = a UUID, the bearer token for later calls
- key (f1) = 61 bytes:
02 || tag[16] || iv[12] || ciphertext[32](section 0) - alternative_login_info.renewal_token was empty
MyTeamSpeakIdData (f9): user_public_key 32 bytes, encrypted_user_private_key 112 bytes,
account_creation_time, my_teamspeak_id 33 bytes = 01 20 <32 bytes> (version 1, length 32, id),
public_signature.
1c. Recovery / backup key
32 bytes, shown base64 in the account UI. Its use (unwrapping the data key without the password) is not recovered.
1d. One encrypted sync item (a bookmark) with its KNOWN plaintext
From POST /synchronization requestServerItems reply,
Sync_ItemClasses_Data_Detail { item_uuid=1 string, item_version=2 string, item_blob=3 bytes }:
- item_uuid =
43c5d058-4803-3cd8-b844-15fdcc92e730 - item_version =
5d831e3c-d5aa-0e66-7af2-a0feb13af048 - item_blob (274 bytes, note leading
0x11) =1114428e2059c038f339b1c1a6bc4eb0eac1f4479ec2764ea53c0b4dd0fc46264642cab8908d295933fac43152154f58990909e933d899b86d4776ae8c52f61575b33932d4667fc3fb50e78a2a056dc6c8cd74e3e2446275e0d351c7139bc686a3555b4dd3dd6fdfae1c7176e84f99b30df52dbe9b95e28d9545e2dd3188fec7e34ac233fb7de6d21db8795a45186ff91a01d23189d072d6afb60cad9b1c834e35acf04006daa1d6e439750afaa00a4c63e53f10cf54d2820108c1b859b394d0ba7d80b38de402b631da9ba4daedbff4d9de85cdeae7466b516ac5abe9af4d43e9df1c9bf8951d12e9f5c42ce7cf802443d4bc051902164ec510126130dd962e54778518ae0a44947e6dd19975eef8b33ce8
Known plaintext — after the official client logged in, it wrote this same bookmark DECRYPTED into its
local settings.db (ProtobufItems row 5). That decrypted Item_Data protobuf (231 bytes) is:
- hex =
122434336335643035382d343830332d336364382d623834342d3135666463633932653733301a2464373663663736312d316163332d373434662d366234312d363636333863626431653033200330003a00420048f7f5dbd506820189010a19457269c48d516f76205465616d537065616b20736572766572120f7365727665722e6c69786b6f2e657518834e22056e696765722a00320744656661756c743a0042004a0050005a0744656661756c74620744656661756c746a0070007a1c2b547967324a7478453876524e5a702b4a6955426e6d4268304d593d8a0100900100980100b00101 - decoded:
Item_Data{ item_uuid(2)="43c5d058-4803-3cd8-b844-15fdcc92e730", version(3)="d76cf761-1ac3-744f-6b41-66638cbd1e03", manipulated(4)=3, item_type(6)=0 BOOKMARK, timestamp(9)=…, bookmark(16)=Bookmark_Data{ name="EriÄQov TeamSpeak server", address="server.lixko.eu", port=9987, nickname="niger", capture/playback/hotkey="Default", server_uid="+Tyg2JtxE8vRNZp+JiUBnmBh0MY=", send_mytsid=1 } }
Caveat resolved: the local copy's sync_version_uuid is d76cf761-… while the captured wire
item_version is 5d831e3c-… because the local row is a later revision. Decrypting the captured ciphertext
nevertheless yields a valid 231-byte Item_Data for the same bookmark; its older revision has
manipulated=ITEM_ADDED (0), whereas the committed local copy has ITEM_NOT_MANIPULATED (3). The apparent
43-byte overhead was an artifact of a truncated capture: the 274 captured bytes contain IV + ciphertext
- only 27 of the 64 SHA-512 bytes. The complete frame is 311 bytes.
2. What the binary tells us about the construction
Strings/asserts from the client binaries (teamcrypto + cloud_sync_client):
- teamcrypto exposes ONLY:
aes::Gcm,aes::Ctr,sha2::Sha2<mbedtls_sha256>,sha2::Sha2<mbedtls_sha512>,drbg::DRBG_Block_Ctr. (So HKDF/PBKDF2/scrypt/Argon2 strings elsewhere are from statically-linked OpenSSL and are NOT used by the sync path.) cloud_sync_client/src/lib/Encryption.cppasserts: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.cppasserts:!salt.empty(). ⇒ items use a salt.Account_Serializing.protohasAuth_Token_Package { auth_token, encrypted_encryption_password, encryption_tag, iv }andAccount_Datacarrieskey,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_key32B,encrypted_user_private_key112B) 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
0x11byte), 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 unwrappingLoginSession.keyas 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) thenldr x9,[x8,#0x48]; blr x9. So the crypto is behindEncryption'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@0x8931a8takes (email, password, device), converts the 3 Java Strings, then callsAccount_Manager_Implvtable 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 alea 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
0xa39350gets the local item list via0xa47ab0, readsitem_uuidthrough0xa4cdc0, readssync_version_uuidthrough0xa4ce50, 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 itemITEM_ADDED. - Update transaction near
0xa45220changes clean/changed items toITEM_CHANGED, but preservesITEM_ADDEDandITEM_DELETED. - The observed tombstone path marks
ITEM_DELETEDand calls0xa4d0b0for a fresh revision. - Remote-accept/import paths near
0xa48896and0xa494xxsetITEM_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.