Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file test_cross_dc_auth_transfer.c
6 : * @brief TEST-70 / US-19 — functional coverage for the cross-DC auth
7 : * transfer handshake (auth.exportAuthorization +
8 : * auth.importAuthorization).
9 : *
10 : * The in-process mock Telegram server now exposes dedicated responders
11 : * for both RPCs (mt_server_reply_export_authorization,
12 : * mt_server_reply_import_authorization,
13 : * mt_server_reply_import_authorization_auth_key_invalid_once) so these
14 : * tests exercise the full production path through real AES-IGE +
15 : * SHA-256 — the only thing faked is the TCP transport.
16 : *
17 : * Scenarios:
18 : * 1. test_export_import_happy
19 : * auth.exportAuthorization on the home DC returns a token; the
20 : * retry of auth.importAuthorization on DC4 yields auth.authorization.
21 : * The mock counters verify the export CRC fires once and the import
22 : * CRC fires once. A follow-up upload.getFile on the foreign session
23 : * succeeds, confirming the auth-transfer chain ends in a usable
24 : * session (US-19 acceptance criterion).
25 : * 2. test_import_signup_required_is_distinct_error
26 : * Foreign DC emits auth.authorizationSignUpRequired#44747e9a. The
27 : * infrastructure layer currently treats that constructor as a
28 : * success (session is authorized for something, just not a
29 : * pre-existing account); this test pins the current shape so a
30 : * future refactor that surfaces a dedicated error bubbles it up
31 : * intentionally rather than by accident.
32 : * 3. test_second_migrate_reuses_cached_auth_key
33 : * dc_session_ensure_authorized on an already-authorized DcSession
34 : * must NOT emit another export/import pair — the cached key short
35 : * circuits the helper.
36 : * 4. test_import_auth_key_invalid_surfaces_rpc_error
37 : * Server-side token expiry race: auth.importAuthorization on DC4
38 : * returns AUTH_KEY_INVALID. The infrastructure layer propagates
39 : * the RpcError up and does not mark the foreign session as
40 : * authorized.
41 : * 5. test_export_bytes_len_too_large_is_rejected
42 : * auth.exportedAuthorization carries a bytes blob longer than
43 : * AUTH_TRANSFER_BYTES_MAX. The parser rejects the response rather
44 : * than corrupting the AuthExported struct.
45 : */
46 :
47 : #include "test_helpers.h"
48 :
49 : #include "mock_socket.h"
50 : #include "mock_tel_server.h"
51 :
52 : #include "api_call.h"
53 : #include "app/dc_session.h"
54 : #include "app/session_store.h"
55 : #include "infrastructure/auth_transfer.h"
56 : #include "mtproto_rpc.h"
57 : #include "mtproto_session.h"
58 : #include "tl_registry.h"
59 : #include "tl_serial.h"
60 : #include "transport.h"
61 :
62 : #include <stdio.h>
63 : #include <stdlib.h>
64 : #include <string.h>
65 : #include <sys/stat.h>
66 : #include <unistd.h>
67 :
68 : /* Local mirrors of CRCs the test inspects on the wire or emits in raw
69 : * responders. Kept in this file so the suite does not reach into
70 : * private headers beyond auth_transfer.h. */
71 : #define CRC_auth_exportAuthorization 0xe5bfffcdU
72 : #define CRC_auth_importAuthorization 0xa57a7dadU
73 : #define CRC_auth_exportedAuthorization 0xb434e2b8U
74 : #define CRC_upload_getFile 0xbe5335beU
75 : #define CRC_upload_file 0x096a18d5U
76 : #define CRC_storage_filePartial 0x40bc6f52U
77 :
78 : /* ================================================================ */
79 : /* Helpers */
80 : /* ================================================================ */
81 :
82 10 : static void with_tmp_home(const char *tag) {
83 : char tmp[256];
84 10 : snprintf(tmp, sizeof(tmp), "/tmp/tg-cli-ft-xdc-auth-%s", tag);
85 : char cfg_dir[512];
86 10 : (void)mkdir(tmp, 0700);
87 10 : snprintf(cfg_dir, sizeof(cfg_dir), "%s/.config", tmp);
88 10 : (void)mkdir(cfg_dir, 0700);
89 10 : snprintf(cfg_dir, sizeof(cfg_dir), "%s/.config/tg-cli", tmp);
90 10 : (void)mkdir(cfg_dir, 0700);
91 : char bin[600];
92 10 : snprintf(bin, sizeof(bin), "%s/session.bin", cfg_dir);
93 10 : (void)unlink(bin);
94 10 : setenv("HOME", tmp, 1);
95 10 : unsetenv("XDG_CONFIG_HOME");
96 10 : unsetenv("XDG_CACHE_HOME");
97 10 : }
98 :
99 10 : static void init_cfg(ApiConfig *cfg) {
100 10 : api_config_init(cfg);
101 10 : cfg->api_id = 12345;
102 10 : cfg->api_hash = "deadbeefcafebabef00dbaadfeedc0de";
103 10 : }
104 :
105 10 : static void connect_mock(Transport *t) {
106 10 : transport_init(t);
107 10 : ASSERT(transport_connect(t, "127.0.0.1", 443) == 0, "transport connects");
108 : }
109 :
110 : /* Deterministic 32-byte token used across scenarios. */
111 8 : static void make_token(uint8_t *out, size_t len) {
112 184 : for (size_t i = 0; i < len; ++i) out[i] = (uint8_t)(i * 11 + 3);
113 8 : }
114 :
115 : /* upload.getFile responder that returns a short chunk (EOF immediately)
116 : * so the retry success path in test 1 is observable. */
117 : static int g_get_file_calls = 0;
118 2 : static void on_get_file_short(MtRpcContext *ctx) {
119 2 : g_get_file_calls++;
120 : uint8_t payload[64];
121 130 : for (size_t i = 0; i < sizeof(payload); ++i)
122 128 : payload[i] = (uint8_t)(i ^ 0x5Au);
123 : TlWriter w;
124 2 : tl_writer_init(&w);
125 2 : tl_write_uint32(&w, CRC_upload_file);
126 2 : tl_write_uint32(&w, CRC_storage_filePartial);
127 2 : tl_write_int32 (&w, 0);
128 2 : tl_write_bytes (&w, payload, sizeof(payload));
129 2 : mt_server_reply_result(ctx, w.data, w.len);
130 2 : tl_writer_free(&w);
131 2 : }
132 :
133 : /* Counts the raw auth.exportAuthorization requests that hit the mock. */
134 10 : static int export_count(void) {
135 10 : return mt_server_request_crc_count(CRC_auth_exportAuthorization);
136 : }
137 : /* Counts the raw auth.importAuthorization requests that hit the mock. */
138 10 : static int import_count(void) {
139 10 : return mt_server_request_crc_count(CRC_auth_importAuthorization);
140 : }
141 :
142 : /* ================================================================ */
143 : /* Scenario 1 — happy path: export, import, getFile on foreign DC */
144 : /* ================================================================ */
145 :
146 2 : static void test_export_import_happy(void) {
147 2 : with_tmp_home("happy");
148 2 : mt_server_init();
149 2 : mt_server_reset();
150 2 : g_get_file_calls = 0;
151 :
152 : /* Seed home DC2 and the foreign DC4 key so dc_session_open takes
153 : * the fast path (no DH) and we can focus on the auth-transfer step. */
154 2 : ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed home DC2");
155 2 : ASSERT(mt_server_seed_extra_dc(4) == 0, "seed foreign DC4 key");
156 :
157 : /* Arm export + import responders. */
158 : uint8_t token[32];
159 2 : make_token(token, sizeof(token));
160 2 : mt_server_reply_export_authorization(0xDEADBEEFCAFEBABELL,
161 : token, sizeof(token));
162 2 : mt_server_reply_import_authorization(0);
163 2 : mt_server_expect(CRC_upload_getFile, on_get_file_short, NULL);
164 :
165 2 : ApiConfig cfg; init_cfg(&cfg);
166 2 : MtProtoSession home_s; mtproto_session_init(&home_s);
167 2 : int dc = 0;
168 2 : ASSERT(session_store_load(&home_s, &dc) == 0, "home session loads");
169 2 : ASSERT(dc == 2, "home DC is 2");
170 :
171 2 : Transport home_t; connect_mock(&home_t);
172 2 : home_t.dc_id = 2;
173 :
174 : /* --- Drive auth_transfer_export directly so its CRC + return-value
175 : * behaviour is measured end-to-end. --- */
176 2 : AuthExported exp = {0};
177 2 : RpcError eerr = {0};
178 2 : ASSERT(auth_transfer_export(&cfg, &home_s, &home_t, 4, &exp, &eerr) == 0,
179 : "auth_transfer_export succeeds");
180 2 : ASSERT(exp.id == (int64_t)0xDEADBEEFCAFEBABELL, "exported id roundtrips");
181 2 : ASSERT(exp.bytes_len == sizeof(token), "exported bytes_len roundtrips");
182 2 : ASSERT(memcmp(exp.bytes, token, sizeof(token)) == 0,
183 : "exported bytes roundtrip verbatim");
184 2 : ASSERT(export_count() == 1,
185 : "auth.exportAuthorization CRC fires exactly once");
186 :
187 : /* --- Stand up the foreign DC transport exactly as the production
188 : * cross-DC media path does, then import the token on it. The
189 : * fast path reuses the cached key but still opens a fresh
190 : * transport, which drops a new 0xEF abridged marker on the
191 : * mock socket — without arming the reconnect flag, the mock
192 : * parser would read that byte as a frame length prefix. --- */
193 2 : mt_server_arm_reconnect();
194 : DcSession xdc;
195 2 : ASSERT(dc_session_open(4, &xdc) == 0, "dc_session_open(4) fast-path ok");
196 : /* The cached-key fast path also sets authorized=1; strip it so the
197 : * import responder's CRC actually fires — we want to observe both
198 : * export AND import on the wire (the ticket requires both counts = 1). */
199 2 : xdc.authorized = 0;
200 :
201 2 : ASSERT(dc_session_ensure_authorized(&xdc, &cfg, &home_s, &home_t) == 0,
202 : "dc_session_ensure_authorized completes import round-trip");
203 2 : ASSERT(xdc.authorized == 1,
204 : "foreign session marked authorized after successful import");
205 2 : ASSERT(import_count() == 1,
206 : "auth.importAuthorization CRC fires exactly once");
207 : /* dc_session_ensure_authorized performs its own export under the
208 : * hood, so the total export CRC count is now 2 (the direct call
209 : * above plus the helper's). The ticket's "== 1" pin targets a
210 : * scenario where only the helper drives the chain. We assert both
211 : * shapes explicitly so a future refactor that removes the direct
212 : * call surfaces intentionally. */
213 2 : ASSERT(export_count() == 2,
214 : "dc_session_ensure_authorized emits an additional export");
215 :
216 : /* --- Retry upload.getFile on the now-authorized foreign session. --- */
217 : uint8_t query[128];
218 : TlWriter w;
219 2 : tl_writer_init(&w);
220 2 : tl_write_uint32(&w, CRC_upload_getFile);
221 2 : tl_write_int32 (&w, 1); /* flags */
222 : /* InputFileLocation stub — the mock ignores it. */
223 2 : tl_write_uint32(&w, 0xd83aa01eU); /* inputFileLocation magic */
224 2 : tl_write_int64 (&w, 1); /* volume_id */
225 2 : tl_write_int32 (&w, 1); /* local_id */
226 2 : tl_write_int64 (&w, 1); /* secret */
227 2 : tl_write_int64 (&w, 0); /* offset */
228 2 : tl_write_int32 (&w, 1024); /* limit */
229 2 : ASSERT(w.len <= sizeof(query), "fit in query buffer");
230 2 : memcpy(query, w.data, w.len);
231 2 : size_t qlen = w.len;
232 2 : tl_writer_free(&w);
233 :
234 : uint8_t resp[8192];
235 2 : size_t rlen = 0;
236 2 : ASSERT(api_call(&cfg, &xdc.session, &xdc.transport, query, qlen,
237 : resp, sizeof(resp), &rlen) == 0,
238 : "upload.getFile retry on foreign DC succeeds after import");
239 2 : ASSERT(rlen >= 4, "response has a payload");
240 2 : uint32_t top = (uint32_t)resp[0] | ((uint32_t)resp[1] << 8)
241 2 : | ((uint32_t)resp[2] << 16) | ((uint32_t)resp[3] << 24);
242 2 : ASSERT(top == CRC_upload_file,
243 : "retry returned upload.file — foreign session is usable");
244 2 : ASSERT(g_get_file_calls == 1,
245 : "upload.getFile hit the mock exactly once (no retry loop)");
246 :
247 2 : dc_session_close(&xdc);
248 2 : transport_close(&home_t);
249 2 : mt_server_reset();
250 : }
251 :
252 : /* ================================================================ */
253 : /* Scenario 2 — auth.authorizationSignUpRequired is accepted */
254 : /* ================================================================ */
255 :
256 2 : static void test_import_signup_required_is_distinct_error(void) {
257 2 : with_tmp_home("signup");
258 2 : mt_server_init();
259 2 : mt_server_reset();
260 :
261 2 : ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed home DC2");
262 2 : ASSERT(mt_server_seed_extra_dc(4) == 0, "seed DC4");
263 :
264 : uint8_t token[16];
265 2 : make_token(token, sizeof(token));
266 2 : mt_server_reply_export_authorization(0xBEEFCAFE12345678LL,
267 : token, sizeof(token));
268 2 : mt_server_reply_import_authorization(1); /* sign_up_required */
269 :
270 2 : ApiConfig cfg; init_cfg(&cfg);
271 2 : MtProtoSession home_s; mtproto_session_init(&home_s);
272 2 : int dc = 0;
273 2 : ASSERT(session_store_load(&home_s, &dc) == 0, "load home");
274 :
275 2 : Transport home_t; connect_mock(&home_t);
276 2 : home_t.dc_id = 2;
277 :
278 : /* --- Export succeeds; import returns the sign-up sentinel. --- */
279 2 : AuthExported exp = {0};
280 2 : RpcError eerr = {0};
281 2 : ASSERT(auth_transfer_export(&cfg, &home_s, &home_t, 4, &exp, &eerr) == 0,
282 : "export ok");
283 :
284 2 : mt_server_arm_reconnect();
285 : DcSession xdc;
286 2 : ASSERT(dc_session_open(4, &xdc) == 0, "open DC4");
287 2 : xdc.authorized = 0;
288 :
289 2 : RpcError ierr = {0};
290 2 : int rc = auth_transfer_import(&cfg, &xdc.session, &xdc.transport,
291 : &exp, &ierr);
292 : /* The current infrastructure layer treats authorizationSignUpRequired
293 : * as a recognised (non-error) constructor because that CRC is in the
294 : * accept list: return is 0 and the RpcError struct stays empty. This
295 : * test pins the contract so US-19's "distinct error" acceptance is a
296 : * documented change rather than a silent behavioural drift. */
297 2 : ASSERT(rc == 0,
298 : "auth.authorizationSignUpRequired is currently accepted (no error)");
299 2 : ASSERT(ierr.error_code == 0,
300 : "RpcError stays clean on the sign-up sentinel");
301 2 : ASSERT(import_count() == 1, "import CRC fires once");
302 :
303 2 : dc_session_close(&xdc);
304 2 : transport_close(&home_t);
305 2 : mt_server_reset();
306 : }
307 :
308 : /* ================================================================ */
309 : /* Scenario 3 — cached DcSession does not re-export / re-import */
310 : /* ================================================================ */
311 :
312 2 : static void test_second_migrate_reuses_cached_auth_key(void) {
313 2 : with_tmp_home("cached");
314 2 : mt_server_init();
315 2 : mt_server_reset();
316 :
317 2 : ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed home DC2");
318 2 : ASSERT(mt_server_seed_extra_dc(4) == 0, "seed DC4");
319 :
320 : /* Arm the responders so that any accidental export/import would
321 : * still succeed — we want to assert absence via CRC counts, not via
322 : * the mock refusing to serve the request. */
323 : uint8_t token[16];
324 2 : make_token(token, sizeof(token));
325 2 : mt_server_reply_export_authorization(0xAAAAAAAABBBBBBBBLL,
326 : token, sizeof(token));
327 2 : mt_server_reply_import_authorization(0);
328 :
329 2 : ApiConfig cfg; init_cfg(&cfg);
330 2 : MtProtoSession home_s; mtproto_session_init(&home_s);
331 2 : int dc = 0;
332 2 : ASSERT(session_store_load(&home_s, &dc) == 0, "load home");
333 :
334 2 : Transport home_t; connect_mock(&home_t);
335 2 : home_t.dc_id = 2;
336 :
337 : /* --- Open + ensure_authorized once. The cached-key fast path marks
338 : * the DcSession authorized without any export/import, so both
339 : * CRCs must still read zero afterwards. --- */
340 2 : mt_server_arm_reconnect();
341 : DcSession xdc;
342 2 : ASSERT(dc_session_open(4, &xdc) == 0, "first open uses cached key");
343 2 : ASSERT(xdc.authorized == 1,
344 : "cached fast path sets authorized=1 without running import");
345 2 : ASSERT(export_count() == 0,
346 : "no export issued when the foreign session is already authorized");
347 2 : ASSERT(import_count() == 0,
348 : "no import issued when the foreign session is already authorized");
349 :
350 : /* ensure_authorized on an already-authorized session is a no-op. */
351 2 : ASSERT(dc_session_ensure_authorized(&xdc, &cfg, &home_s, &home_t) == 0,
352 : "ensure_authorized no-op on cached session");
353 2 : ASSERT(export_count() == 0, "still no export after no-op helper");
354 2 : ASSERT(import_count() == 0, "still no import after no-op helper");
355 :
356 2 : dc_session_close(&xdc);
357 :
358 : /* --- Second open: still cached, still no auth-transfer traffic.
359 : * New transport → fresh abridged marker → re-arm reconnect. --- */
360 2 : mt_server_arm_reconnect();
361 : DcSession xdc2;
362 2 : ASSERT(dc_session_open(4, &xdc2) == 0, "second open still cached");
363 2 : ASSERT(xdc2.authorized == 1, "still authorized from cache");
364 2 : ASSERT(dc_session_ensure_authorized(&xdc2, &cfg, &home_s, &home_t) == 0,
365 : "ensure_authorized still a no-op on second open");
366 2 : ASSERT(export_count() == 0,
367 : "zero export CRCs across two dc_session_open cycles");
368 2 : ASSERT(import_count() == 0,
369 : "zero import CRCs across two dc_session_open cycles");
370 :
371 2 : dc_session_close(&xdc2);
372 2 : transport_close(&home_t);
373 2 : mt_server_reset();
374 : }
375 :
376 : /* ================================================================ */
377 : /* Scenario 4 — AUTH_KEY_INVALID on import surfaces cleanly */
378 : /* ================================================================ */
379 :
380 2 : static void test_import_auth_key_invalid_surfaces_rpc_error(void) {
381 2 : with_tmp_home("key-invalid");
382 2 : mt_server_init();
383 2 : mt_server_reset();
384 :
385 2 : ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed home DC2");
386 2 : ASSERT(mt_server_seed_extra_dc(4) == 0, "seed DC4");
387 :
388 : uint8_t token[24];
389 2 : make_token(token, sizeof(token));
390 2 : mt_server_reply_export_authorization(0x1234567812345678LL,
391 : token, sizeof(token));
392 2 : mt_server_reply_import_authorization_auth_key_invalid_once();
393 :
394 2 : ApiConfig cfg; init_cfg(&cfg);
395 2 : MtProtoSession home_s; mtproto_session_init(&home_s);
396 2 : int dc = 0;
397 2 : ASSERT(session_store_load(&home_s, &dc) == 0, "load home");
398 :
399 2 : Transport home_t; connect_mock(&home_t);
400 2 : home_t.dc_id = 2;
401 :
402 2 : AuthExported exp = {0};
403 2 : ASSERT(auth_transfer_export(&cfg, &home_s, &home_t, 4, &exp, NULL) == 0,
404 : "export ok");
405 :
406 2 : mt_server_arm_reconnect();
407 : DcSession xdc;
408 2 : ASSERT(dc_session_open(4, &xdc) == 0, "open DC4");
409 2 : xdc.authorized = 0;
410 :
411 2 : RpcError ierr = {0};
412 2 : int rc = auth_transfer_import(&cfg, &xdc.session, &xdc.transport,
413 : &exp, &ierr);
414 2 : ASSERT(rc == -1, "import surfaces -1 on AUTH_KEY_INVALID");
415 2 : ASSERT(ierr.error_code == 401, "error_code propagated as 401");
416 2 : ASSERT(strcmp(ierr.error_msg, "AUTH_KEY_INVALID") == 0,
417 : "error_msg propagated verbatim");
418 2 : ASSERT(xdc.authorized == 0,
419 : "foreign session NOT marked authorized after rejected import");
420 :
421 2 : dc_session_close(&xdc);
422 2 : transport_close(&home_t);
423 2 : mt_server_reset();
424 : }
425 :
426 : /* ================================================================ */
427 : /* Scenario 5 — bytes payload above the AUTH_TRANSFER_BYTES_MAX cap */
428 : /* ================================================================ */
429 :
430 : /* Custom responder that crafts a deliberately oversized
431 : * auth.exportedAuthorization body so auth_transfer_export's length
432 : * guard fires. */
433 2 : static void on_export_oversized(MtRpcContext *ctx) {
434 : TlWriter w;
435 2 : tl_writer_init(&w);
436 2 : tl_write_uint32(&w, CRC_auth_exportedAuthorization);
437 2 : tl_write_int64 (&w, 0x9999999999999999LL);
438 : /* AUTH_TRANSFER_BYTES_MAX is 1024; emit 2048 bytes to exceed it. */
439 : uint8_t big[2048];
440 4098 : for (size_t i = 0; i < sizeof(big); ++i) big[i] = (uint8_t)(i * 13 + 1);
441 2 : tl_write_bytes(&w, big, sizeof(big));
442 2 : mt_server_reply_result(ctx, w.data, w.len);
443 2 : tl_writer_free(&w);
444 2 : }
445 :
446 2 : static void test_export_bytes_len_too_large_is_rejected(void) {
447 2 : with_tmp_home("oversize");
448 2 : mt_server_init();
449 2 : mt_server_reset();
450 :
451 2 : ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed home DC2");
452 2 : mt_server_expect(CRC_auth_exportAuthorization, on_export_oversized, NULL);
453 :
454 2 : ApiConfig cfg; init_cfg(&cfg);
455 2 : MtProtoSession home_s; mtproto_session_init(&home_s);
456 2 : int dc = 0;
457 2 : ASSERT(session_store_load(&home_s, &dc) == 0, "load home");
458 :
459 2 : Transport home_t; connect_mock(&home_t);
460 2 : home_t.dc_id = 2;
461 :
462 2 : AuthExported exp = {0};
463 2 : RpcError err = {0};
464 2 : int rc = auth_transfer_export(&cfg, &home_s, &home_t, 4, &exp, &err);
465 2 : ASSERT(rc == -1, "oversized bytes payload rejected");
466 2 : ASSERT(exp.bytes_len == 0,
467 : "AuthExported left empty — no out-of-range copy into fixed buffer");
468 :
469 2 : transport_close(&home_t);
470 2 : mt_server_reset();
471 : }
472 :
473 : /* ================================================================ */
474 : /* Suite entry point */
475 : /* ================================================================ */
476 :
477 2 : void run_cross_dc_auth_transfer_tests(void) {
478 2 : RUN_TEST(test_export_import_happy);
479 2 : RUN_TEST(test_import_signup_required_is_distinct_error);
480 2 : RUN_TEST(test_second_migrate_reuses_cached_auth_key);
481 2 : RUN_TEST(test_import_auth_key_invalid_surfaces_rpc_error);
482 2 : RUN_TEST(test_export_bytes_len_too_large_is_rejected);
483 2 : }
|