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,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 <lib> <vaddr> <len>` — capstone arm64 disassembler, annotates adrp+add string loads and
bl targets with .symtab/.dynsym names.
- `xref.py <lib> <string>` / `xrefaddr.py <lib> <vaddr> [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]].

View File

@@ -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" <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.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.

View File

@@ -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.

View File

@@ -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/<endpoint>
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> <method-name ASCII> <serialized protobuf request>
e.g. `05 "login" <LoginData>`, `12 "requestServerItems" <Sync_Request_ItemClasses>`.
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.

File diff suppressed because it is too large Load Diff