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:
378
ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md
Normal file
378
ts3-client/docs/myteamspeak/CRYPTO_RE_SALT.md
Normal 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.
|
||||
Reference in New Issue
Block a user