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 1349 : static char *store_path(void) {
88 1349 : const char *cfg = platform_config_dir();
89 1349 : if (!cfg) return NULL;
90 1349 : char *p = NULL;
91 1349 : if (asprintf(&p, "%s/tg-cli/session.bin", cfg) == -1) return NULL;
92 1349 : return p;
93 : }
94 :
95 259 : static char *store_tmp_path(void) {
96 259 : const char *cfg = platform_config_dir();
97 259 : if (!cfg) return NULL;
98 259 : char *p = NULL;
99 259 : if (asprintf(&p, "%s/tg-cli/session.bin.tmp", cfg) == -1) return NULL;
100 259 : return p;
101 : }
102 :
103 262 : static int ensure_dir(void) {
104 262 : const char *cfg_dir = platform_config_dir();
105 262 : if (!cfg_dir) return -1;
106 : char dir_path[1024];
107 262 : snprintf(dir_path, sizeof(dir_path), "%s/tg-cli", cfg_dir);
108 262 : if (fs_mkdir_p(dir_path, 0700) != 0) {
109 1 : logger_log(LOG_ERROR, "session_store: cannot create %s", dir_path);
110 1 : return -1;
111 : }
112 261 : 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 544 : static int lock_file(const char *path, int how) {
134 : /* O_CREAT so the lock file can exist even before first write. */
135 544 : int fd = open(path, O_CREAT | O_RDWR, 0600);
136 544 : if (fd == -1) {
137 1 : logger_log(LOG_ERROR, "session_store: open(%s) failed: %s",
138 1 : path, strerror(errno));
139 1 : return -1;
140 : }
141 543 : if (flock(fd, how | LOCK_NB) == -1) {
142 1 : if (errno == EWOULDBLOCK || errno == EAGAIN) {
143 1 : 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 1 : close(fd);
151 1 : return -1;
152 : }
153 542 : return fd;
154 : }
155 :
156 542 : static void unlock_file(int fd) {
157 542 : if (fd >= 0) close(fd);
158 542 : }
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 542 : static int read_file_locked(StoreFile *out) {
210 542 : memset(out, 0, sizeof(*out));
211 :
212 1084 : RAII_STRING char *path = store_path();
213 542 : if (!path) return -1;
214 :
215 1084 : RAII_FILE FILE *f = fopen(path, "rb");
216 542 : if (!f) return +1;
217 :
218 : uint8_t buf[STORE_MAX_SIZE];
219 542 : size_t n = fread(buf, 1, sizeof(buf), f);
220 542 : if (n < STORE_HEADER) {
221 142 : logger_log(LOG_WARN, "session_store: truncated header");
222 142 : return -1;
223 : }
224 400 : if (memcmp(buf, STORE_MAGIC, 4) != 0) {
225 1 : logger_log(LOG_WARN, "session_store: bad magic");
226 1 : return -1;
227 : }
228 : int32_t version;
229 399 : 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 399 : if (version == STORE_VERSION_V1) {
236 8 : if (n < STORE_V1_TOTAL_SIZE) {
237 1 : logger_log(LOG_WARN, "session_store: truncated v1 payload");
238 1 : return -1;
239 : }
240 : int32_t dc_id;
241 : uint64_t server_salt;
242 : uint64_t session_id;
243 7 : memcpy(&dc_id, buf + 8, 4);
244 7 : memcpy(&server_salt, buf + 12, 8);
245 7 : memcpy(&session_id, buf + 20, 8);
246 7 : out->home_dc_id = dc_id;
247 7 : out->count = 1;
248 7 : out->entries[0].dc_id = dc_id;
249 7 : out->entries[0].server_salt = server_salt;
250 7 : out->entries[0].session_id = session_id;
251 7 : memcpy(out->entries[0].auth_key, buf + 28, MTPROTO_AUTH_KEY_SIZE);
252 7 : logger_log(LOG_INFO,
253 : "session_store: migrated v1 file for DC%d "
254 : "(will rewrite as v2 on next save)", dc_id);
255 7 : return 0;
256 : }
257 391 : 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 3 : if (version > STORE_VERSION) {
266 2 : logger_log(LOG_WARN,
267 : "session_store: unsupported version %d "
268 : "— unknown session version, upgrade client",
269 : version);
270 : } else {
271 1 : logger_log(LOG_WARN,
272 : "session_store: unsupported version %d", version);
273 : }
274 3 : return -1;
275 : }
276 388 : memcpy(&out->home_dc_id, buf + 8, 4);
277 388 : memcpy(&out->count, buf + 12, 4);
278 388 : if (out->count > SESSION_STORE_MAX_DCS) {
279 1 : logger_log(LOG_WARN, "session_store: count %u too large", out->count);
280 1 : return -1;
281 : }
282 387 : size_t need = STORE_HEADER + (size_t)out->count * STORE_ENTRY_SIZE;
283 387 : if (n < need) {
284 1 : logger_log(LOG_WARN, "session_store: truncated body");
285 1 : return -1;
286 : }
287 902 : for (uint32_t i = 0; i < out->count; i++) {
288 516 : size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
289 516 : memcpy(&out->entries[i].dc_id, buf + off + 0, 4);
290 516 : memcpy(&out->entries[i].server_salt, buf + off + 4, 8);
291 516 : memcpy(&out->entries[i].session_id, buf + off + 12, 8);
292 516 : memcpy( out->entries[i].auth_key, buf + off + 20, 256);
293 : }
294 386 : 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 259 : static int write_file_atomic(const StoreFile *st) {
305 518 : RAII_STRING char *path = store_path();
306 518 : RAII_STRING char *tmp_path = store_tmp_path();
307 259 : if (!path || !tmp_path) return -1;
308 :
309 : /* Build the serialised buffer. */
310 : uint8_t buf[STORE_MAX_SIZE];
311 259 : memset(buf, 0, sizeof(buf));
312 259 : memcpy(buf, STORE_MAGIC, 4);
313 259 : int32_t version = STORE_VERSION;
314 259 : memcpy(buf + 4, &version, 4);
315 259 : memcpy(buf + 8, &st->home_dc_id, 4);
316 259 : memcpy(buf + 12, &st->count, 4);
317 579 : for (uint32_t i = 0; i < st->count; i++) {
318 320 : size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
319 320 : memcpy(buf + off + 0, &st->entries[i].dc_id, 4);
320 320 : memcpy(buf + off + 4, &st->entries[i].server_salt, 8);
321 320 : memcpy(buf + off + 12, &st->entries[i].session_id, 8);
322 320 : memcpy(buf + off + 20, st->entries[i].auth_key, 256);
323 : }
324 259 : size_t total = STORE_HEADER + (size_t)st->count * STORE_ENTRY_SIZE;
325 :
326 : /* Write to tmp. */
327 259 : int tfd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
328 259 : if (tfd == -1) {
329 2 : logger_log(LOG_ERROR, "session_store: cannot open tmp %s: %s",
330 2 : tmp_path, strerror(errno));
331 2 : return -1;
332 : }
333 :
334 257 : ssize_t n = write(tfd, buf, total);
335 257 : 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 257 : 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 257 : close(tfd);
350 :
351 : /* Set permissions on the tmp file before rename. */
352 257 : 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 257 : 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 257 : 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 532 : static int find_entry(const StoreFile *st, int dc_id) {
372 612 : for (uint32_t i = 0; i < st->count; i++) {
373 452 : if (st->entries[i].dc_id == dc_id) return (int)i;
374 : }
375 160 : return -1;
376 : }
377 :
378 259 : static void populate_entry(StoreEntry *e, int dc_id, const MtProtoSession *s) {
379 259 : e->dc_id = dc_id;
380 259 : e->server_salt = s->server_salt;
381 259 : e->session_id = s->session_id;
382 259 : memcpy(e->auth_key, s->auth_key, MTPROTO_AUTH_KEY_SIZE);
383 259 : }
384 :
385 270 : static void apply_entry(MtProtoSession *s, const StoreEntry *e) {
386 270 : s->server_salt = e->server_salt;
387 270 : s->session_id = e->session_id;
388 270 : memcpy(s->auth_key, e->auth_key, MTPROTO_AUTH_KEY_SIZE);
389 270 : s->has_auth_key = 1;
390 270 : s->seq_no = 0;
391 270 : s->last_msg_id = 0;
392 270 : }
393 :
394 : /* -------------------------------------------------------------------------
395 : * Upsert (read-modify-write under exclusive lock)
396 : * ---------------------------------------------------------------------- */
397 :
398 262 : static int upsert(int dc_id, const MtProtoSession *s, int set_home) {
399 262 : if (!s || !s->has_auth_key) return -1;
400 :
401 262 : if (ensure_dir() != 0) return -1;
402 :
403 522 : RAII_STRING char *path = store_path();
404 261 : if (!path) return -1;
405 :
406 : /* Acquire exclusive lock. */
407 261 : int lock_fd = lock_file(path, LOCK_EX);
408 261 : if (lock_fd == -1) return -1;
409 :
410 : StoreFile st;
411 260 : int rc = read_file_locked(&st);
412 260 : if (rc < 0) {
413 : /* Corrupt: start fresh. The user is re-authenticating anyway. */
414 139 : memset(&st, 0, sizeof(st));
415 : }
416 :
417 260 : int idx = find_entry(&st, dc_id);
418 260 : if (idx < 0) {
419 158 : if (st.count >= SESSION_STORE_MAX_DCS) {
420 1 : logger_log(LOG_ERROR,
421 : "session_store: no slot left for DC%d", dc_id);
422 1 : unlock_file(lock_fd);
423 1 : return -1;
424 : }
425 157 : idx = (int)st.count++;
426 : }
427 259 : populate_entry(&st.entries[idx], dc_id, s);
428 :
429 259 : if (set_home || st.home_dc_id == 0) {
430 237 : st.home_dc_id = dc_id;
431 : }
432 :
433 259 : int write_rc = write_file_atomic(&st);
434 :
435 259 : unlock_file(lock_fd);
436 :
437 259 : if (write_rc != 0) return -1;
438 :
439 257 : logger_log(LOG_INFO,
440 : "session_store: persisted DC%d (home=%d, count=%u)",
441 : dc_id, st.home_dc_id, st.count);
442 257 : return 0;
443 : }
444 :
445 : /* -------------------------------------------------------------------------
446 : * Public API
447 : * ---------------------------------------------------------------------- */
448 :
449 238 : int session_store_save(const MtProtoSession *s, int dc_id) {
450 238 : return upsert(dc_id, s, /*set_home=*/1);
451 : }
452 :
453 24 : int session_store_save_dc(int dc_id, const MtProtoSession *s) {
454 24 : return upsert(dc_id, s, /*set_home=*/0);
455 : }
456 :
457 257 : int session_store_load(MtProtoSession *s, int *dc_id) {
458 257 : if (!s || !dc_id) return -1;
459 :
460 514 : RAII_STRING char *path = store_path();
461 257 : if (!path) return -1;
462 :
463 : /* Shared lock — wait for any in-progress write to finish. */
464 257 : int lock_fd = lock_file(path, LOCK_SH);
465 257 : if (lock_fd == -1) return -1;
466 :
467 : StoreFile st;
468 256 : int rc = read_file_locked(&st);
469 :
470 256 : unlock_file(lock_fd);
471 :
472 256 : if (rc != 0) return -1;
473 246 : if (st.count == 0 || st.home_dc_id == 0) return -1;
474 :
475 246 : int idx = find_entry(&st, st.home_dc_id);
476 246 : if (idx < 0) {
477 1 : logger_log(LOG_WARN,
478 : "session_store: home DC%d has no entry", st.home_dc_id);
479 1 : return -1;
480 : }
481 245 : apply_entry(s, &st.entries[idx]);
482 245 : *dc_id = st.home_dc_id;
483 245 : logger_log(LOG_INFO, "session_store: loaded home DC%d", *dc_id);
484 245 : return 0;
485 : }
486 :
487 26 : int session_store_load_dc(int dc_id, MtProtoSession *s) {
488 26 : if (!s) return -1;
489 :
490 52 : RAII_STRING char *path = store_path();
491 26 : if (!path) return -1;
492 :
493 26 : int lock_fd = lock_file(path, LOCK_SH);
494 26 : if (lock_fd == -1) return -1;
495 :
496 : StoreFile st;
497 26 : int rc = read_file_locked(&st);
498 :
499 26 : unlock_file(lock_fd);
500 :
501 26 : if (rc != 0) return -1;
502 :
503 26 : int idx = find_entry(&st, dc_id);
504 26 : if (idx < 0) return -1;
505 :
506 25 : apply_entry(s, &st.entries[idx]);
507 25 : logger_log(LOG_INFO, "session_store: loaded DC%d", dc_id);
508 25 : return 0;
509 : }
510 :
511 4 : void session_store_clear(void) {
512 8 : RAII_STRING char *path = store_path();
513 4 : if (!path) return;
514 4 : if (remove(path) == 0)
515 4 : logger_log(LOG_INFO, "session_store: cleared");
516 : }
|