Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file app/session_store.c
6 : * @brief Multi-DC session persistence (v2).
7 : *
8 : * Write safety:
9 : * - An exclusive advisory lock (flock LOCK_EX | LOCK_NB) is acquired on
10 : * the session file before every read-modify-write cycle. A non-blocking
11 : * attempt is used; if the lock is busy we return -1 with a log message
12 : * so the caller can surface "another tg-cli process is using this session".
13 : * - The new content is written to `session.bin.tmp`, fsync'd, then renamed
14 : * atomically over `session.bin`. This prevents a truncated file on crash
15 : * or disk-full.
16 : * - Reads also take a shared lock (LOCK_SH | LOCK_NB) so they never observe
17 : * a partially-written file.
18 : * - On Windows the lock calls are compiled out (advisory locks are not
19 : * available via flock on MinGW); the atomic-rename pattern still applies.
20 : */
21 :
22 : #include "app/session_store.h"
23 :
24 : #include "fs_util.h"
25 : #include "logger.h"
26 : #include "platform/path.h"
27 : #include "raii.h"
28 :
29 : #include <errno.h>
30 : #include <fcntl.h>
31 : #include <stdio.h>
32 : #include <stdlib.h>
33 : #include <string.h>
34 : #include <unistd.h>
35 :
36 : #if !defined(_WIN32)
37 : # include <sys/file.h> /* flock(2) */
38 : #else
39 : # include <windows.h> /* LockFileEx / UnlockFileEx */
40 : # include <io.h> /* _open, _close, _get_osfhandle */
41 : # include <share.h> /* _SH_DENYNO */
42 : # include <sys/stat.h> /* _S_IREAD, _S_IWRITE */
43 : #endif
44 :
45 : #define STORE_MAGIC "TGCS"
46 : /* Current on-disk schema version. Increment (and teach the loader the previous
47 : * layout) whenever the header or entry format changes. Older files are
48 : * silently upgraded on the next save; newer ("future") files are refused
49 : * with a distinct diagnostic so an out-of-date client does not clobber
50 : * session state it cannot safely parse. */
51 : #define STORE_VERSION 2
52 : /* Historical single-DC layout predating multi-DC support (US-16 landing).
53 : * 4 bytes magic "TGCS"
54 : * 4 bytes version = 1
55 : * 4 bytes dc_id (int32 LE)
56 : * 8 bytes server_salt (uint64 LE)
57 : * 8 bytes session_id (uint64 LE)
58 : * 256 bytes auth_key
59 : *
60 : * The whole payload is exactly 284 bytes. Retained as a read-only
61 : * compatibility path — on load the entry is lifted into the v2 multi-DC
62 : * struct, marked as home_dc, and the next successful save() atomically
63 : * rewrites the file in v2 format. */
64 : #define STORE_VERSION_V1 1
65 : #define STORE_V1_TOTAL_SIZE 284
66 : #define STORE_HEADER 16 /* magic+ver+home_dc+count */
67 : #define STORE_ENTRY_SIZE 276 /* 4 + 8 + 8 + 256 */
68 : #define STORE_MAX_SIZE (STORE_HEADER + SESSION_STORE_MAX_DCS * STORE_ENTRY_SIZE)
69 :
70 : typedef struct {
71 : int32_t dc_id;
72 : uint64_t server_salt;
73 : uint64_t session_id;
74 : uint8_t auth_key[MTPROTO_AUTH_KEY_SIZE];
75 : } StoreEntry;
76 :
77 : typedef struct {
78 : int32_t home_dc_id;
79 : uint32_t count;
80 : StoreEntry entries[SESSION_STORE_MAX_DCS];
81 : } StoreFile;
82 :
83 : /* -------------------------------------------------------------------------
84 : * Path helpers
85 : * ---------------------------------------------------------------------- */
86 :
87 2813 : static char *store_path(void) {
88 2813 : const char *cfg = platform_config_dir();
89 2813 : if (!cfg) return NULL;
90 2813 : char *p = NULL;
91 2813 : if (asprintf(&p, "%s/tg-cli/session.bin", cfg) == -1) return NULL;
92 2813 : return p;
93 : }
94 :
95 541 : static char *store_tmp_path(void) {
96 541 : const char *cfg = platform_config_dir();
97 541 : if (!cfg) return NULL;
98 541 : char *p = NULL;
99 541 : if (asprintf(&p, "%s/tg-cli/session.bin.tmp", cfg) == -1) return NULL;
100 541 : return p;
101 : }
102 :
103 548 : static int ensure_dir(void) {
104 548 : const char *cfg_dir = platform_config_dir();
105 548 : if (!cfg_dir) return -1;
106 : char dir_path[1024];
107 548 : snprintf(dir_path, sizeof(dir_path), "%s/tg-cli", cfg_dir);
108 548 : if (fs_mkdir_p(dir_path, 0700) != 0) {
109 2 : logger_log(LOG_ERROR, "session_store: cannot create %s", dir_path);
110 2 : return -1;
111 : }
112 546 : return 0;
113 : }
114 :
115 : /* -------------------------------------------------------------------------
116 : * Advisory locking
117 : *
118 : * Returns an open fd that holds the lock, or -1 on error / busy.
119 : * The caller must pass the fd to unlock_file() to release and close it.
120 : * POSIX: uses flock(2).
121 : * Windows: uses LockFileEx via _get_osfhandle; _open provides the fd.
122 : * ---------------------------------------------------------------------- */
123 :
124 : #if !defined(_WIN32)
125 :
126 : /**
127 : * @brief Open @p path and acquire an advisory flock.
128 : *
129 : * @param path Path to lock (created if absent).
130 : * @param how LOCK_EX for exclusive, LOCK_SH for shared.
131 : * @return open fd with lock held, or -1 on failure.
132 : */
133 1128 : static int lock_file(const char *path, int how) {
134 : /* O_CREAT so the lock file can exist even before first write. */
135 1128 : int fd = open(path, O_CREAT | O_RDWR, 0600);
136 1128 : if (fd == -1) {
137 4 : logger_log(LOG_ERROR, "session_store: open(%s) failed: %s",
138 4 : path, strerror(errno));
139 4 : return -1;
140 : }
141 1124 : if (flock(fd, how | LOCK_NB) == -1) {
142 3 : if (errno == EWOULDBLOCK || errno == EAGAIN) {
143 3 : logger_log(LOG_ERROR,
144 : "session_store: another tg-cli process is using "
145 : "this session; please close it first");
146 : } else {
147 0 : logger_log(LOG_ERROR, "session_store: flock failed: %s",
148 0 : strerror(errno));
149 : }
150 3 : close(fd);
151 3 : return -1;
152 : }
153 1121 : return fd;
154 : }
155 :
156 1121 : static void unlock_file(int fd) {
157 1121 : if (fd >= 0) close(fd);
158 1121 : }
159 :
160 : #else /* _WIN32 — LockFileEx advisory locking */
161 :
162 : static int lock_file(const char *path, int how) {
163 : int fd = _open(path,
164 : _O_CREAT | _O_RDWR | _O_BINARY,
165 : _S_IREAD | _S_IWRITE);
166 : if (fd == -1) {
167 : logger_log(LOG_ERROR, "session_store: _open(%s) failed", path);
168 : return -1;
169 : }
170 : HANDLE h = (HANDLE)_get_osfhandle(fd);
171 : OVERLAPPED ov;
172 : memset(&ov, 0, sizeof(ov));
173 : DWORD flags = LOCKFILE_FAIL_IMMEDIATELY;
174 : if (how == LOCK_EX) flags |= LOCKFILE_EXCLUSIVE_LOCK;
175 : if (!LockFileEx(h, flags, 0, 1, 0, &ov)) {
176 : logger_log(LOG_ERROR,
177 : "session_store: LockFileEx failed (another process may "
178 : "hold the session lock)");
179 : _close(fd);
180 : return -1;
181 : }
182 : return fd;
183 : }
184 :
185 : static void unlock_file(int fd) {
186 : if (fd >= 0) {
187 : HANDLE h = (HANDLE)_get_osfhandle(fd);
188 : OVERLAPPED ov;
189 : memset(&ov, 0, sizeof(ov));
190 : UnlockFileEx(h, 0, 1, 0, &ov);
191 : _close(fd);
192 : }
193 : }
194 :
195 : #endif /* _WIN32 */
196 :
197 : /* -------------------------------------------------------------------------
198 : * Serialise / deserialise
199 : * ---------------------------------------------------------------------- */
200 :
201 : /* Read the file into `out` if present. The caller is responsible for holding
202 : * a shared lock before calling this function.
203 : *
204 : * Returns:
205 : * 0 on success (file existed and parsed cleanly)
206 : * +1 on "file absent" (caller treats as empty store)
207 : * -1 on corrupt / unsupported
208 : */
209 1121 : static int read_file_locked(StoreFile *out) {
210 1121 : memset(out, 0, sizeof(*out));
211 :
212 2242 : RAII_STRING char *path = store_path();
213 1121 : if (!path) return -1;
214 :
215 2242 : RAII_FILE FILE *f = fopen(path, "rb");
216 1121 : if (!f) return +1;
217 :
218 : uint8_t buf[STORE_MAX_SIZE];
219 1121 : size_t n = fread(buf, 1, sizeof(buf), f);
220 1121 : if (n < STORE_HEADER) {
221 294 : logger_log(LOG_WARN, "session_store: truncated header");
222 294 : return -1;
223 : }
224 827 : if (memcmp(buf, STORE_MAGIC, 4) != 0) {
225 3 : logger_log(LOG_WARN, "session_store: bad magic");
226 3 : return -1;
227 : }
228 : int32_t version;
229 824 : memcpy(&version, buf + 4, 4);
230 : /* Legacy v1 single-DC payload — lift into a v2-shaped in-memory store so
231 : * the rest of the code (and the next save) is version-agnostic. The file
232 : * on disk is left untouched until an explicit save rewrites it atomically
233 : * in v2 format, which preserves the migration's crash-safety: if the
234 : * client exits between load and save, the v1 bytes remain usable. */
235 824 : if (version == STORE_VERSION_V1) {
236 16 : if (n < STORE_V1_TOTAL_SIZE) {
237 2 : logger_log(LOG_WARN, "session_store: truncated v1 payload");
238 2 : return -1;
239 : }
240 : int32_t dc_id;
241 : uint64_t server_salt;
242 : uint64_t session_id;
243 14 : memcpy(&dc_id, buf + 8, 4);
244 14 : memcpy(&server_salt, buf + 12, 8);
245 14 : memcpy(&session_id, buf + 20, 8);
246 14 : out->home_dc_id = dc_id;
247 14 : out->count = 1;
248 14 : out->entries[0].dc_id = dc_id;
249 14 : out->entries[0].server_salt = server_salt;
250 14 : out->entries[0].session_id = session_id;
251 14 : memcpy(out->entries[0].auth_key, buf + 28, MTPROTO_AUTH_KEY_SIZE);
252 14 : logger_log(LOG_INFO,
253 : "session_store: migrated v1 file for DC%d "
254 : "(will rewrite as v2 on next save)", dc_id);
255 14 : return 0;
256 : }
257 808 : if (version != STORE_VERSION) {
258 : /* Either an out-of-bounds garbage number (classic corruption) or a
259 : * *future* version that a newer client wrote. In the latter case
260 : * the safe reaction is to refuse the load and never overwrite — we
261 : * ask the operator to upgrade the client instead of silently
262 : * clobbering their real session with a freshly-re-authenticated
263 : * v2 one. Both paths share the "unsupported version" prefix so
264 : * existing corruption-recovery assertions keep matching. */
265 7 : if (version > STORE_VERSION) {
266 5 : logger_log(LOG_WARN,
267 : "session_store: unsupported version %d "
268 : "— unknown session version, upgrade client",
269 : version);
270 : } else {
271 2 : logger_log(LOG_WARN,
272 : "session_store: unsupported version %d", version);
273 : }
274 7 : return -1;
275 : }
276 801 : memcpy(&out->home_dc_id, buf + 8, 4);
277 801 : memcpy(&out->count, buf + 12, 4);
278 801 : if (out->count > SESSION_STORE_MAX_DCS) {
279 2 : logger_log(LOG_WARN, "session_store: count %u too large", out->count);
280 2 : return -1;
281 : }
282 799 : size_t need = STORE_HEADER + (size_t)out->count * STORE_ENTRY_SIZE;
283 799 : if (n < need) {
284 2 : logger_log(LOG_WARN, "session_store: truncated body");
285 2 : return -1;
286 : }
287 1883 : for (uint32_t i = 0; i < out->count; i++) {
288 1086 : size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
289 1086 : memcpy(&out->entries[i].dc_id, buf + off + 0, 4);
290 1086 : memcpy(&out->entries[i].server_salt, buf + off + 4, 8);
291 1086 : memcpy(&out->entries[i].session_id, buf + off + 12, 8);
292 1086 : memcpy( out->entries[i].auth_key, buf + off + 20, 256);
293 : }
294 797 : return 0;
295 : }
296 :
297 : /**
298 : * @brief Atomically write @p st to the session file.
299 : *
300 : * Writes to a sibling .tmp file, fsync's it, then renames it over the real
301 : * path. The rename is atomic on POSIX. The caller must hold an exclusive
302 : * lock before calling this function.
303 : */
304 541 : static int write_file_atomic(const StoreFile *st) {
305 1082 : RAII_STRING char *path = store_path();
306 1082 : RAII_STRING char *tmp_path = store_tmp_path();
307 541 : if (!path || !tmp_path) return -1;
308 :
309 : /* Build the serialised buffer. */
310 : uint8_t buf[STORE_MAX_SIZE];
311 541 : memset(buf, 0, sizeof(buf));
312 541 : memcpy(buf, STORE_MAGIC, 4);
313 541 : int32_t version = STORE_VERSION;
314 541 : memcpy(buf + 4, &version, 4);
315 541 : memcpy(buf + 8, &st->home_dc_id, 4);
316 541 : memcpy(buf + 12, &st->count, 4);
317 1229 : for (uint32_t i = 0; i < st->count; i++) {
318 688 : size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
319 688 : memcpy(buf + off + 0, &st->entries[i].dc_id, 4);
320 688 : memcpy(buf + off + 4, &st->entries[i].server_salt, 8);
321 688 : memcpy(buf + off + 12, &st->entries[i].session_id, 8);
322 688 : memcpy(buf + off + 20, st->entries[i].auth_key, 256);
323 : }
324 541 : size_t total = STORE_HEADER + (size_t)st->count * STORE_ENTRY_SIZE;
325 :
326 : /* Write to tmp. */
327 541 : int tfd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
328 541 : if (tfd == -1) {
329 4 : logger_log(LOG_ERROR, "session_store: cannot open tmp %s: %s",
330 4 : tmp_path, strerror(errno));
331 4 : return -1;
332 : }
333 :
334 537 : ssize_t n = write(tfd, buf, total);
335 537 : if (n < 0 || (size_t)n != total) {
336 0 : logger_log(LOG_ERROR, "session_store: short write to %s", tmp_path);
337 0 : close(tfd);
338 0 : unlink(tmp_path);
339 0 : return -1;
340 : }
341 :
342 : #if !defined(_WIN32)
343 537 : if (fsync(tfd) != 0) {
344 0 : logger_log(LOG_WARN, "session_store: fsync(%s) failed: %s",
345 0 : tmp_path, strerror(errno));
346 : /* Non-fatal — proceed with rename. */
347 : }
348 : #endif
349 537 : close(tfd);
350 :
351 : /* Set permissions on the tmp file before rename. */
352 537 : if (fs_ensure_permissions(tmp_path, 0600) != 0) {
353 0 : logger_log(LOG_WARN, "session_store: cannot set 0600 on %s", tmp_path);
354 : }
355 :
356 : /* Atomic rename. */
357 537 : if (rename(tmp_path, path) != 0) {
358 0 : logger_log(LOG_ERROR, "session_store: rename(%s, %s) failed: %s",
359 0 : tmp_path, path, strerror(errno));
360 0 : unlink(tmp_path);
361 0 : return -1;
362 : }
363 537 : return 0;
364 : }
365 :
366 : /* -------------------------------------------------------------------------
367 : * Internal entry helpers
368 : * ---------------------------------------------------------------------- */
369 :
370 : /* Find the index of @p dc_id in the store, or -1 if absent. */
371 1098 : static int find_entry(const StoreFile *st, int dc_id) {
372 1264 : for (uint32_t i = 0; i < st->count; i++) {
373 933 : if (st->entries[i].dc_id == dc_id) return (int)i;
374 : }
375 331 : return -1;
376 : }
377 :
378 541 : static void populate_entry(StoreEntry *e, int dc_id, const MtProtoSession *s) {
379 541 : e->dc_id = dc_id;
380 541 : e->server_salt = s->server_salt;
381 541 : e->session_id = s->session_id;
382 541 : memcpy(e->auth_key, s->auth_key, MTPROTO_AUTH_KEY_SIZE);
383 541 : }
384 :
385 550 : static void apply_entry(MtProtoSession *s, const StoreEntry *e) {
386 550 : s->server_salt = e->server_salt;
387 550 : s->session_id = e->session_id;
388 550 : memcpy(s->auth_key, e->auth_key, MTPROTO_AUTH_KEY_SIZE);
389 550 : s->has_auth_key = 1;
390 550 : s->seq_no = 0;
391 550 : s->last_msg_id = 0;
392 550 : }
393 :
394 : /* -------------------------------------------------------------------------
395 : * Upsert (read-modify-write under exclusive lock)
396 : * ---------------------------------------------------------------------- */
397 :
398 550 : static int upsert(int dc_id, const MtProtoSession *s, int set_home) {
399 550 : if (!s || !s->has_auth_key) return -1;
400 :
401 548 : if (ensure_dir() != 0) return -1;
402 :
403 1092 : RAII_STRING char *path = store_path();
404 546 : if (!path) return -1;
405 :
406 : /* Acquire exclusive lock. */
407 546 : int lock_fd = lock_file(path, LOCK_EX);
408 546 : if (lock_fd == -1) return -1;
409 :
410 : StoreFile st;
411 543 : int rc = read_file_locked(&st);
412 543 : if (rc < 0) {
413 : /* Corrupt: start fresh. The user is re-authenticating anyway. */
414 287 : memset(&st, 0, sizeof(st));
415 : }
416 :
417 543 : int idx = find_entry(&st, dc_id);
418 543 : if (idx < 0) {
419 326 : if (st.count >= SESSION_STORE_MAX_DCS) {
420 2 : logger_log(LOG_ERROR,
421 : "session_store: no slot left for DC%d", dc_id);
422 2 : unlock_file(lock_fd);
423 2 : return -1;
424 : }
425 324 : idx = (int)st.count++;
426 : }
427 541 : populate_entry(&st.entries[idx], dc_id, s);
428 :
429 541 : if (set_home || st.home_dc_id == 0) {
430 496 : st.home_dc_id = dc_id;
431 : }
432 :
433 541 : int write_rc = write_file_atomic(&st);
434 :
435 541 : unlock_file(lock_fd);
436 :
437 541 : if (write_rc != 0) return -1;
438 :
439 537 : logger_log(LOG_INFO,
440 : "session_store: persisted DC%d (home=%d, count=%u)",
441 : dc_id, st.home_dc_id, st.count);
442 537 : return 0;
443 : }
444 :
445 : /* -------------------------------------------------------------------------
446 : * Public API
447 : * ---------------------------------------------------------------------- */
448 :
449 498 : int session_store_save(const MtProtoSession *s, int dc_id) {
450 498 : return upsert(dc_id, s, /*set_home=*/1);
451 : }
452 :
453 52 : int session_store_save_dc(int dc_id, const MtProtoSession *s) {
454 52 : return upsert(dc_id, s, /*set_home=*/0);
455 : }
456 :
457 526 : int session_store_load(MtProtoSession *s, int *dc_id) {
458 526 : if (!s || !dc_id) return -1;
459 :
460 1050 : RAII_STRING char *path = store_path();
461 525 : if (!path) return -1;
462 :
463 : /* Shared lock — wait for any in-progress write to finish. */
464 525 : int lock_fd = lock_file(path, LOCK_SH);
465 525 : if (lock_fd == -1) return -1;
466 :
467 : StoreFile st;
468 522 : int rc = read_file_locked(&st);
469 :
470 522 : unlock_file(lock_fd);
471 :
472 522 : if (rc != 0) return -1;
473 499 : if (st.count == 0 || st.home_dc_id == 0) return -1;
474 :
475 499 : int idx = find_entry(&st, st.home_dc_id);
476 499 : if (idx < 0) {
477 2 : logger_log(LOG_WARN,
478 : "session_store: home DC%d has no entry", st.home_dc_id);
479 2 : return -1;
480 : }
481 497 : apply_entry(s, &st.entries[idx]);
482 497 : *dc_id = st.home_dc_id;
483 497 : logger_log(LOG_INFO, "session_store: loaded home DC%d", *dc_id);
484 497 : return 0;
485 : }
486 :
487 57 : int session_store_load_dc(int dc_id, MtProtoSession *s) {
488 57 : if (!s) return -1;
489 :
490 114 : RAII_STRING char *path = store_path();
491 57 : if (!path) return -1;
492 :
493 57 : int lock_fd = lock_file(path, LOCK_SH);
494 57 : if (lock_fd == -1) return -1;
495 :
496 : StoreFile st;
497 56 : int rc = read_file_locked(&st);
498 :
499 56 : unlock_file(lock_fd);
500 :
501 56 : if (rc != 0) return -1;
502 :
503 56 : int idx = find_entry(&st, dc_id);
504 56 : if (idx < 0) return -1;
505 :
506 53 : apply_entry(s, &st.entries[idx]);
507 53 : logger_log(LOG_INFO, "session_store: loaded DC%d", dc_id);
508 53 : return 0;
509 : }
510 :
511 23 : void session_store_clear(void) {
512 46 : RAII_STRING char *path = store_path();
513 23 : if (!path) return;
514 23 : if (remove(path) == 0)
515 18 : logger_log(LOG_INFO, "session_store: cleared");
516 : }
|