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 1349 : static char *store_path(void) {
83 1349 : const char *cfg = platform_config_dir();
84 1349 : if (!cfg) return NULL;
85 1349 : char *p = NULL;
86 1349 : if (asprintf(&p, "%s/tg-cli/session.bin", cfg) == -1) return NULL;
87 1349 : return p;
88 : }
89 :
90 259 : static char *store_tmp_path(void) {
91 259 : const char *cfg = platform_config_dir();
92 259 : if (!cfg) return NULL;
93 259 : char *p = NULL;
94 259 : if (asprintf(&p, "%s/tg-cli/session.bin.tmp", cfg) == -1) return NULL;
95 259 : return p;
96 : }
97 :
98 262 : static int ensure_dir(void) {
99 262 : const char *cfg_dir = platform_config_dir();
100 262 : if (!cfg_dir) return -1;
101 : char dir_path[1024];
102 262 : snprintf(dir_path, sizeof(dir_path), "%s/tg-cli", cfg_dir);
103 262 : if (fs_mkdir_p(dir_path, 0700) != 0) {
104 1 : logger_log(LOG_ERROR, "session_store: cannot create %s", dir_path);
105 1 : return -1;
106 : }
107 261 : 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 544 : static int lock_file(const char *path, int how) {
128 : /* O_CREAT so the lock file can exist even before first write. */
129 544 : int fd = open(path, O_CREAT | O_RDWR, 0600);
130 544 : if (fd == -1) {
131 1 : logger_log(LOG_ERROR, "session_store: open(%s) failed: %s",
132 1 : path, strerror(errno));
133 1 : return -1;
134 : }
135 543 : if (flock(fd, how | LOCK_NB) == -1) {
136 1 : if (errno == EWOULDBLOCK || errno == EAGAIN) {
137 1 : 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 1 : close(fd);
145 1 : return -1;
146 : }
147 542 : return fd;
148 : }
149 :
150 542 : static void unlock_file(int fd) {
151 542 : if (fd >= 0) close(fd);
152 542 : }
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 542 : static int read_file_locked(StoreFile *out) {
180 542 : memset(out, 0, sizeof(*out));
181 :
182 1084 : RAII_STRING char *path = store_path();
183 542 : if (!path) return -1;
184 :
185 1084 : RAII_FILE FILE *f = fopen(path, "rb");
186 542 : if (!f) return +1;
187 :
188 : uint8_t buf[STORE_MAX_SIZE];
189 542 : size_t n = fread(buf, 1, sizeof(buf), f);
190 542 : if (n < STORE_HEADER) {
191 142 : logger_log(LOG_WARN, "session_store: truncated header");
192 142 : return -1;
193 : }
194 400 : if (memcmp(buf, STORE_MAGIC, 4) != 0) {
195 1 : logger_log(LOG_WARN, "session_store: bad magic");
196 1 : return -1;
197 : }
198 : int32_t version;
199 399 : 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 399 : if (version == STORE_VERSION_V1) {
206 8 : if (n < STORE_V1_TOTAL_SIZE) {
207 1 : logger_log(LOG_WARN, "session_store: truncated v1 payload");
208 1 : return -1;
209 : }
210 : int32_t dc_id;
211 : uint64_t server_salt;
212 : uint64_t session_id;
213 7 : memcpy(&dc_id, buf + 8, 4);
214 7 : memcpy(&server_salt, buf + 12, 8);
215 7 : memcpy(&session_id, buf + 20, 8);
216 7 : out->home_dc_id = dc_id;
217 7 : out->count = 1;
218 7 : out->entries[0].dc_id = dc_id;
219 7 : out->entries[0].server_salt = server_salt;
220 7 : out->entries[0].session_id = session_id;
221 7 : memcpy(out->entries[0].auth_key, buf + 28, MTPROTO_AUTH_KEY_SIZE);
222 7 : logger_log(LOG_INFO,
223 : "session_store: migrated v1 file for DC%d "
224 : "(will rewrite as v2 on next save)", dc_id);
225 7 : return 0;
226 : }
227 391 : 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 3 : if (version > STORE_VERSION) {
236 2 : logger_log(LOG_WARN,
237 : "session_store: unsupported version %d "
238 : "— unknown session version, upgrade client",
239 : version);
240 : } else {
241 1 : logger_log(LOG_WARN,
242 : "session_store: unsupported version %d", version);
243 : }
244 3 : return -1;
245 : }
246 388 : memcpy(&out->home_dc_id, buf + 8, 4);
247 388 : memcpy(&out->count, buf + 12, 4);
248 388 : if (out->count > SESSION_STORE_MAX_DCS) {
249 1 : logger_log(LOG_WARN, "session_store: count %u too large", out->count);
250 1 : return -1;
251 : }
252 387 : size_t need = STORE_HEADER + (size_t)out->count * STORE_ENTRY_SIZE;
253 387 : if (n < need) {
254 1 : logger_log(LOG_WARN, "session_store: truncated body");
255 1 : return -1;
256 : }
257 902 : for (uint32_t i = 0; i < out->count; i++) {
258 516 : size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
259 516 : memcpy(&out->entries[i].dc_id, buf + off + 0, 4);
260 516 : memcpy(&out->entries[i].server_salt, buf + off + 4, 8);
261 516 : memcpy(&out->entries[i].session_id, buf + off + 12, 8);
262 516 : memcpy( out->entries[i].auth_key, buf + off + 20, 256);
263 : }
264 386 : 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 259 : static int write_file_atomic(const StoreFile *st) {
275 518 : RAII_STRING char *path = store_path();
276 518 : RAII_STRING char *tmp_path = store_tmp_path();
277 259 : if (!path || !tmp_path) return -1;
278 :
279 : /* Build the serialised buffer. */
280 : uint8_t buf[STORE_MAX_SIZE];
281 259 : memset(buf, 0, sizeof(buf));
282 259 : memcpy(buf, STORE_MAGIC, 4);
283 259 : int32_t version = STORE_VERSION;
284 259 : memcpy(buf + 4, &version, 4);
285 259 : memcpy(buf + 8, &st->home_dc_id, 4);
286 259 : memcpy(buf + 12, &st->count, 4);
287 579 : for (uint32_t i = 0; i < st->count; i++) {
288 320 : size_t off = STORE_HEADER + (size_t)i * STORE_ENTRY_SIZE;
289 320 : memcpy(buf + off + 0, &st->entries[i].dc_id, 4);
290 320 : memcpy(buf + off + 4, &st->entries[i].server_salt, 8);
291 320 : memcpy(buf + off + 12, &st->entries[i].session_id, 8);
292 320 : memcpy(buf + off + 20, st->entries[i].auth_key, 256);
293 : }
294 259 : size_t total = STORE_HEADER + (size_t)st->count * STORE_ENTRY_SIZE;
295 :
296 : /* Write to tmp. */
297 259 : int tfd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
298 259 : if (tfd == -1) {
299 2 : logger_log(LOG_ERROR, "session_store: cannot open tmp %s: %s",
300 2 : tmp_path, strerror(errno));
301 2 : return -1;
302 : }
303 :
304 257 : ssize_t n = write(tfd, buf, total);
305 257 : 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 257 : 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 257 : close(tfd);
320 :
321 : /* Set permissions on the tmp file before rename. */
322 257 : 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 257 : 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 257 : 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 532 : static int find_entry(const StoreFile *st, int dc_id) {
342 612 : for (uint32_t i = 0; i < st->count; i++) {
343 452 : if (st->entries[i].dc_id == dc_id) return (int)i;
344 : }
345 160 : return -1;
346 : }
347 :
348 259 : static void populate_entry(StoreEntry *e, int dc_id, const MtProtoSession *s) {
349 259 : e->dc_id = dc_id;
350 259 : e->server_salt = s->server_salt;
351 259 : e->session_id = s->session_id;
352 259 : memcpy(e->auth_key, s->auth_key, MTPROTO_AUTH_KEY_SIZE);
353 259 : }
354 :
355 270 : static void apply_entry(MtProtoSession *s, const StoreEntry *e) {
356 270 : s->server_salt = e->server_salt;
357 270 : s->session_id = e->session_id;
358 270 : memcpy(s->auth_key, e->auth_key, MTPROTO_AUTH_KEY_SIZE);
359 270 : s->has_auth_key = 1;
360 270 : s->seq_no = 0;
361 270 : s->last_msg_id = 0;
362 270 : }
363 :
364 : /* -------------------------------------------------------------------------
365 : * Upsert (read-modify-write under exclusive lock)
366 : * ---------------------------------------------------------------------- */
367 :
368 262 : static int upsert(int dc_id, const MtProtoSession *s, int set_home) {
369 262 : if (!s || !s->has_auth_key) return -1;
370 :
371 262 : if (ensure_dir() != 0) return -1;
372 :
373 522 : RAII_STRING char *path = store_path();
374 261 : if (!path) return -1;
375 :
376 : /* Acquire exclusive lock. */
377 261 : int lock_fd = lock_file(path, LOCK_EX);
378 261 : if (lock_fd == -1) return -1;
379 :
380 : StoreFile st;
381 260 : int rc = read_file_locked(&st);
382 260 : if (rc < 0) {
383 : /* Corrupt: start fresh. The user is re-authenticating anyway. */
384 139 : memset(&st, 0, sizeof(st));
385 : }
386 :
387 260 : int idx = find_entry(&st, dc_id);
388 260 : if (idx < 0) {
389 158 : if (st.count >= SESSION_STORE_MAX_DCS) {
390 1 : logger_log(LOG_ERROR,
391 : "session_store: no slot left for DC%d", dc_id);
392 1 : unlock_file(lock_fd);
393 1 : return -1;
394 : }
395 157 : idx = (int)st.count++;
396 : }
397 259 : populate_entry(&st.entries[idx], dc_id, s);
398 :
399 259 : if (set_home || st.home_dc_id == 0) {
400 237 : st.home_dc_id = dc_id;
401 : }
402 :
403 259 : int write_rc = write_file_atomic(&st);
404 :
405 259 : unlock_file(lock_fd);
406 :
407 259 : if (write_rc != 0) return -1;
408 :
409 257 : logger_log(LOG_INFO,
410 : "session_store: persisted DC%d (home=%d, count=%u)",
411 : dc_id, st.home_dc_id, st.count);
412 257 : return 0;
413 : }
414 :
415 : /* -------------------------------------------------------------------------
416 : * Public API
417 : * ---------------------------------------------------------------------- */
418 :
419 238 : int session_store_save(const MtProtoSession *s, int dc_id) {
420 238 : return upsert(dc_id, s, /*set_home=*/1);
421 : }
422 :
423 24 : int session_store_save_dc(int dc_id, const MtProtoSession *s) {
424 24 : return upsert(dc_id, s, /*set_home=*/0);
425 : }
426 :
427 257 : int session_store_load(MtProtoSession *s, int *dc_id) {
428 257 : if (!s || !dc_id) return -1;
429 :
430 514 : RAII_STRING char *path = store_path();
431 257 : if (!path) return -1;
432 :
433 : /* Shared lock — wait for any in-progress write to finish. */
434 257 : int lock_fd = lock_file(path, LOCK_SH);
435 257 : if (lock_fd == -1) return -1;
436 :
437 : StoreFile st;
438 256 : int rc = read_file_locked(&st);
439 :
440 256 : unlock_file(lock_fd);
441 :
442 256 : if (rc != 0) return -1;
443 246 : if (st.count == 0 || st.home_dc_id == 0) return -1;
444 :
445 246 : int idx = find_entry(&st, st.home_dc_id);
446 246 : if (idx < 0) {
447 1 : logger_log(LOG_WARN,
448 : "session_store: home DC%d has no entry", st.home_dc_id);
449 1 : return -1;
450 : }
451 245 : apply_entry(s, &st.entries[idx]);
452 245 : *dc_id = st.home_dc_id;
453 245 : logger_log(LOG_INFO, "session_store: loaded home DC%d", *dc_id);
454 245 : return 0;
455 : }
456 :
457 26 : int session_store_load_dc(int dc_id, MtProtoSession *s) {
458 26 : if (!s) return -1;
459 :
460 52 : RAII_STRING char *path = store_path();
461 26 : if (!path) return -1;
462 :
463 26 : int lock_fd = lock_file(path, LOCK_SH);
464 26 : if (lock_fd == -1) return -1;
465 :
466 : StoreFile st;
467 26 : int rc = read_file_locked(&st);
468 :
469 26 : unlock_file(lock_fd);
470 :
471 26 : if (rc != 0) return -1;
472 :
473 26 : int idx = find_entry(&st, dc_id);
474 26 : if (idx < 0) return -1;
475 :
476 25 : apply_entry(s, &st.entries[idx]);
477 25 : logger_log(LOG_INFO, "session_store: loaded DC%d", dc_id);
478 25 : return 0;
479 : }
480 :
481 4 : void session_store_clear(void) {
482 8 : RAII_STRING char *path = store_path();
483 4 : if (!path) return;
484 4 : if (remove(path) == 0)
485 4 : logger_log(LOG_INFO, "session_store: cleared");
486 : }
|