Telegram Desktop tdata
tdata is the folder where Telegram Desktop keeps its logged-in accounts. opentele2 reads and
writes the format of Telegram Desktop 7.3.1, passcode-locked folders and several accounts per
folder included, with the same Rust code in every ecosystem: TDesktop in Python, loadTdata in
Node, TData::load in Rust.
A tdata folder holds the auth keys of its accounts. Whoever copies it is logged in as you. Never share it, never commit it, and work on a copy when you write.
What is in a folder
- key_datas
- D877F783D5D3EF8Cs
- maps
- configs
- Every file is a
TDF$container: the magicTDF$, the Telegram Desktop version, the payload and an MD5 checksum. On disk a file carries one of the suffixess,1or0; opentele2 reads them in that order and writess. Every saved file carries version 7003001 (7.3.1). key_datais the key file of the data namedata, the default. It holds the local key, wrapped, and the encrypted list of accounts with the active index. Another data name gives another key file (keyFile/key_filewhen loading).- Per account, a folder named after the MD5 of the account’s data name (
D877F783D5D3EF8Cfordata; the second account isdata#2, and so on) holdsmaps, the account map, andconfigs, the MTP config. Next to it, the file of the same name holds the MTP data: the user id, the main DC and the auth keys.
The local key encrypts every file except the key file. It is wrapped under a key derived from the local passcode, an empty one when the folder has no passcode.
Passcodes
A folder with a local passcode needs it to load; a wrong or missing one fails the load
(TDataBadDecryptKey in Python, OT_TDATA_BAD_DECRYPT_KEY in Node, Error::BadDecryptKey in
Rust) and nothing is written.
- Local keys come from PBKDF2, as Telegram Desktop derives them.
- Passcode wraps of the 7.3 key file are Argon2id or scrypt. A new wrap is what Telegram Desktop writes: Argon2id with 64 MiB of memory, 8 passes and 1 lane.
- Reads are capped, so a crafted key file cannot make a load cost unbounded memory or time: Argon2id up to 256 MiB, 64 passes and 16 lanes; scrypt up to N = 2^20, r = 32, p = 16 and 192 MiB. A parameter above a cap, a salt under 8 bytes or an empty passcode is refused.
One Argon2id run holds about 64 MiB while it works. Loading many locked folders at once
(load_many / loadMany / batch::load_many) costs about 64 MiB per thread; cap the thread
count to bound it.
Legacy key files
A key file written before Telegram Desktop 7.3 has the three-blob layout without the passcode-wrap trailer. opentele2 loads it, and a save migrates it to the 7.3 layout under the same local key, as Telegram Desktop does on start; the account files stay valid.
- Python:
SaveTDatamigrates a loaded legacy folder once and keeps the result. - Node:
tdata.legacyKeyFiletells you;migrateTdata(tdata)migrates once, otherwise everysaveTdatamigrates afresh. - Rust:
TData::migrate(ormigrate_with_draws) installs the 7.3 key data;saveof a still-legacyTDatamigrates on the fly.
A folder with a legacy key file is migrated on save; keep a copy of the original.
Several accounts
A folder holds up to six accounts (TDesktop.kMaxAccounts, TDATA_MAX_ACCOUNTS,
TData::MAX_ACCOUNTS). A folder that lists more still loads. The main account is the one at
the active index, else the first one; conversions use it unless you pick another account.
- A new account takes the lowest free index, and the active index does not change.
- Saving refuses two accounts with one index, and more accounts than six or than the folder loaded, whichever is larger.
- An account whose files fail to read is skipped when loading; a folder where none loads fails.
The MTP config
The configs file of an account is its stored MTP config: the environment (production or test),
the DC options with their endpoints, the CDN public keys and the config fields (such as
chat_size_max and edit_time_limit). Every session
conversion takes the main DC’s endpoint from it when the account has one, and from the built-in
production table otherwise. A missing or unreadable config reads as the production default.
from opentele2.td import TDesktop
account = TDesktop("tdata").mainAccount
config = account.MtpConfig # read only; every read is a fresh copy
print(config.environment, config.isTestMode())
print(config.endpoints(account.MainDcId)["selected_ipv4"])
print(sorted(config.dcOptions))import { accountEndpoints, accountMtpConfig, loadTdata } from 'opentele2'
const { mainAccount } = await loadTdata('tdata')
const config = accountMtpConfig(mainAccount)
console.log(config.environment, config.dcs.map((dc) => dc.dcId))
console.log(accountEndpoints(mainAccount)?.selectedIpv4) // main DC by defaultuse opentele2_core::tdata::TData;
fn main() -> opentele2_core::Result<()> {
let tdata = TData::load("tdata", b"", None)?;
let account = tdata.main_account().expect("folder has an account");
let config = account.mtp_config();
println!("{:?} {}", config.environment, config.dc_options.dcs().count());
Ok(())
}Reading and writing
from opentele2.td import TDesktop
tdesk = TDesktop("tdata", passcode="1234") # passcode only for a locked folder
for account in tdesk.accounts:
print(account.index, account.UserId, account.MainDcId)
print("main:", tdesk.mainAccount.UserId)
# Write a copy; a new passcode re-wraps the local key.
tdesk.SaveTData("tdata_copy", passcode="new passcode")TDesktop(basePath, api, passcode, keyFile) loads on construction. SaveTData(basePath, passcode, keyFile) writes the accounts listed in tdesk.accounts, so removing one from the list
removes it from the saved folder.
import { loadTdata, newTdata, saveTdata } from 'opentele2'
const tdata = await loadTdata('tdata', '1234') // passcode only for a locked folder
for (const account of tdata.accounts) {
console.log(account.index, account.userId, account.mainDc)
}
console.log('main:', tdata.mainAccount?.userId, 'legacy key file:', tdata.legacyKeyFile)
// Write a copy under the loaded local key...
await saveTdata(tdata, 'tdata_copy')
// ...or a new folder with its own keys and a passcode.
await saveTdata(await newTdata(tdata.accounts, 'new passcode'), 'tdata_locked')addTdataAccount and setTdataAccounts edit the accounts a later save writes.
use opentele2_core::tdata::{DrawSource, NEW_FOLDER_ACTIVE_INDEX, TData};
fn main() -> opentele2_core::Result<()> {
let tdata = TData::load("tdata", b"1234", None)?; // passcode bytes, key file name
for account in &tdata.accounts {
println!("{} {} {}", account.index, account.user_id, account.main_dc);
}
// Write a copy under the loaded local key...
tdata.save_random("tdata_copy")?;
// ...or a new folder with its own keys and a passcode.
let accounts = tdata.accounts.clone();
let (passcode, active) = (b"new passcode", NEW_FOLDER_ACTIVE_INDEX);
let locked = TData::new_with_draws(accounts, passcode, true, active, DrawSource::os())?;
locked.save_random("tdata_locked")
}add_account and set_accounts edit the accounts a later save writes.
Performance mode
Performance mode is on by default. A new folder without a passcode then gets fixed keys instead of
a fresh key derivation, which makes saving fast, and no configs files are written. Turn it off to write the configs and derive fresh keys:
TDesktop.PerformanceMode(False) in Python, the performanceMode argument of newTdata and
saveTdata in Node, the performance_mode field or argument in Rust. A passcode always gets
freshly derived keys.
Errors
Each failure has a stable code. Python raises the matching opentele2.exception class, Node
rejects with an Error whose code is OT_ plus the core code (for example
OT_TDATA_BAD_DECRYPT_KEY, OT_TFILE_NOT_FOUND), and Rust returns opentele2_core::Error, whose
code() is the same identifier without the prefix.