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