From 1eabf07a732ebf372a4184c567f36f9cb28d7187 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 19:29:01 +0000 Subject: [PATCH 01/25] client: wire per-channel pts into the update loop Channel message updates (new, edit, delete) are now gated by their own channel pts. The first update seen for a channel sets its baseline and is trusted. Gaps trigger updates.getChannelDifference for that channel, using the access hash from the peer cache. Baselines are in-memory only. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- include/cppgram/client.h | 6 +++ src/client.cpp | 83 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/include/cppgram/client.h b/include/cppgram/client.h index 8031233..3574588 100644 --- a/include/cppgram/client.h +++ b/include/cppgram/client.h @@ -13,6 +13,7 @@ #include #include "cppgram/auth_state.h" +#include "cppgram/channel_pts.h" #include "cppgram/convert.h" #include "cppgram/filters.h" #include "cppgram/mtproto/retry.h" @@ -708,6 +709,11 @@ class Client { void handle_membership_update(const mtproto::tl::Update& update); std::shared_ptr input_user_ptr(PeerRef user) const; bool accept_pts(std::int32_t pts, std::int32_t pts_count); + // Per-channel gate. The first update seen for a channel sets its baseline (pts - pts_count) and is trusted. + bool accept_channel_pts(std::int64_t channel_id, std::int32_t pts, std::int32_t pts_count); + // Fetches updates.getChannelDifference for one channel until it is current. Baselines are in-memory only. + void catch_up_channel(std::int64_t channel_id); + ChannelPtsTracker channel_pts_; // Tears down the dead connection and reconnects with backoff, then catches up. Throws when the budget is spent. void reconnect_after_drop(); mtproto::ReconnectBudget reconnect_budget_{}; diff --git a/src/client.cpp b/src/client.cpp index f955f80..155076a 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -318,18 +318,99 @@ void Client::dispatch_message(const Message& message) { } } +namespace { + +std::int64_t channel_id_of_message(const mtproto::tl::Message& message) { + const auto* m = std::get_if(&message.value); + if (!m || !m->peer_id) return 0; + if (const auto* ch = std::get_if(&m->peer_id->value)) return ch->channel_id; + return 0; +} + +} // namespace + void Client::handle_update(const tl::Update& update) { - // Channel updates carry their own per-channel pts, so only the account-wide pts is gated here. + // Account-wide updates are gated by the common pts. Channel updates use their own per-channel pts. if (const auto* nm = std::get_if(&update.value)) { if (!accept_pts(nm->pts, nm->pts_count)) return; } else if (const auto* em = std::get_if(&update.value)) { if (!accept_pts(em->pts, em->pts_count)) return; } else if (const auto* dm = std::get_if(&update.value)) { if (!accept_pts(dm->pts, dm->pts_count)) return; + } else if (const auto* ncm = std::get_if(&update.value)) { + const std::int64_t channel = ncm->message ? channel_id_of_message(*ncm->message) : 0; + if (!accept_channel_pts(channel, ncm->pts, ncm->pts_count)) return; + } else if (const auto* ecm = std::get_if(&update.value)) { + const std::int64_t channel = ecm->message ? channel_id_of_message(*ecm->message) : 0; + if (!accept_channel_pts(channel, ecm->pts, ecm->pts_count)) return; + } else if (const auto* dcm = std::get_if(&update.value)) { + if (!accept_channel_pts(dcm->channel_id, dcm->pts, dcm->pts_count)) return; } dispatch_update_unchecked(update); } +bool Client::accept_channel_pts(std::int64_t channel_id, std::int32_t pts, std::int32_t pts_count) { + if (channel_id == 0) return true; // no channel attached: nothing to gate + if (!channel_pts_.pts(channel_id)) { + // No baseline yet: trust this update and start tracking from the state it implies. + channel_pts_.set_baseline(channel_id, pts - pts_count); + } + switch (channel_pts_.classify(channel_id, pts, pts_count)) { + case PtsDecision::Apply: + channel_pts_.mark_applied(channel_id, pts); + return true; + case PtsDecision::Duplicate: + return false; + case PtsDecision::Gap: + // getChannelDifference re-delivers the missing updates, so this one is dropped. + catch_up_channel(channel_id); + return false; + } + return false; +} + +void Client::catch_up_channel(std::int64_t channel_id) { + require_session(); + const auto base = channel_pts_.pts(channel_id); + const auto peer = cache_.find(PeerKind::Channel, channel_id); + if (!base || !peer || !peer->has_access_hash) { + // Without an access hash the channel cannot be queried. Forget it so the next update re-baselines. + channel_pts_.forget(channel_id); + return; + } + for (;;) { + fn::updates_getChannelDifference req; + req.force = false; + req.channel = std::make_shared(tl::InputChannel{ty::inputChannel{peer->id, peer->access_hash}}); + req.filter = std::make_shared(tl::ChannelMessagesFilter{ty::channelMessagesFilterEmpty{}}); + req.pts = channel_pts_.pts(channel_id).value_or(0); + req.limit = 100; + const tl::updates_ChannelDifference diff = invoke(req); + + if (const auto* empty = std::get_if(&diff.value)) { + channel_pts_.set_baseline(channel_id, empty->pts); + return; + } + if (std::get_if(&diff.value)) { + // Server dropped history for this channel. The next update re-baselines from its own pts. + channel_pts_.forget(channel_id); + return; + } + const auto& d = std::get(diff.value); + cache_users_chats(d.users, d.chats); + for (const auto& m : d.new_messages) { + if (!m) continue; + if (auto msg = detail::message_from_tl(*m, cache_)) dispatch_message(*msg); + } + for (const auto& up : d.other_updates) if (up) dispatch_update_unchecked(*up); + + const auto previous = channel_pts_.pts(channel_id).value_or(0); + channel_pts_.set_baseline(channel_id, d.pts); + // Stop when final, or when the server did not move the channel forward (avoids a loop). + if (d.final_ || d.pts <= previous) return; + } +} + bool Client::accept_pts(std::int32_t pts, std::int32_t pts_count) { if (!state_.valid) return true; switch (classify_pts(state_.pts, pts, pts_count)) { From a7c84b17780a986b4d90b950296044281a4eb5f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 19:29:11 +0000 Subject: [PATCH 02/25] chore: ignore sanitizer build directory Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a690169..c282f82 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ out/ *.pyc bot_workspace/ *.bak +build-san/ From fdbf3e98314e14fbc31513c4da4e27df937cc36d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:26:11 +0000 Subject: [PATCH 03/25] transfer: streamed upload parts and resumable downloads Uploads read each part from disk instead of loading the whole file; the MD5 for inputFile is computed in a streaming pass (skipped for big files). A file that shrinks during upload fails. Downloads resume from an aligned offset in .part (resume_offset, tested). A partial file is kept on failure and discarded on a size mismatch. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- include/cppgram/transfer.h | 5 ++ src/client.cpp | 93 ++++++++++++++++++++++----------- src/transfer.cpp | 6 +++ tests/mtproto/transfer_test.cpp | 12 +++++ 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/include/cppgram/transfer.h b/include/cppgram/transfer.h index 6b0c91b..3dc5a56 100644 --- a/include/cppgram/transfer.h +++ b/include/cppgram/transfer.h @@ -32,6 +32,11 @@ UploadPlan plan_upload(std::uint64_t file_size, std::uint32_t part_size = 0); // Byte offset of part `index` (0-based) in a plan. std::uint64_t upload_part_offset(const UploadPlan& plan, std::uint32_t index); +// Offset to resume a download from, given the size of an existing partial file. upload.getFile needs aligned +// offsets, so the result is the partial size rounded down to a multiple of chunk. Returns 0 (restart) when the +// size is unknown (expected_size 0) or the partial file is larger than the expected file. +std::uint64_t resume_offset(std::uint64_t partial_size, std::uint64_t expected_size, std::uint32_t chunk); + // Download chunks: upload.getFile limit is a power of two, a multiple of 4 KiB, at most 1 MiB. // Returns the number of chunks needed to read file_size bytes. Throws std::invalid_argument on a bad limit. std::uint64_t download_chunk_count(std::uint64_t file_size, std::uint32_t limit); diff --git a/src/client.cpp b/src/client.cpp index 155076a..8a410e5 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -18,6 +18,8 @@ #include "cppgram/storage.h" #include "cppgram/transfer.h" +#include + namespace cppgram { namespace fn = mtproto::tl::functions; @@ -77,20 +79,29 @@ std::string to_hex(const Bytes& data) { return out; } -Bytes read_file(const std::string& path) { - std::ifstream in(path, std::ios::binary); - if (!in) { - throw std::runtime_error("cannot open file: " + path); +// MD5 of a file, read in 64 KiB blocks so the whole file never sits in memory. +Bytes md5_of_stream(std::ifstream& in) { + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + if (ctx == nullptr || EVP_DigestInit_ex(ctx, EVP_md5(), nullptr) != 1) { + EVP_MD_CTX_free(ctx); + throw std::runtime_error("cannot initialise MD5"); } - in.seekg(0, std::ios::end); - const std::streamoff size = in.tellg(); - if (size < 0) throw std::runtime_error("cannot determine size of file: " + path); - in.seekg(0, std::ios::beg); - Bytes data(static_cast(size)); - if (size > 0 && !in.read(reinterpret_cast(data.data()), size)) { - throw std::runtime_error("cannot read file: " + path); + std::vector block(64 * 1024); + while (in) { + in.read(block.data(), static_cast(block.size())); + const std::streamsize got = in.gcount(); + if (got > 0 && EVP_DigestUpdate(ctx, block.data(), static_cast(got)) != 1) { + EVP_MD_CTX_free(ctx); + throw std::runtime_error("MD5 update failed"); + } } - return data; + Bytes digest(EVP_MAX_MD_SIZE); + unsigned int len = 0; + const int ok = EVP_DigestFinal_ex(ctx, digest.data(), &len); + EVP_MD_CTX_free(ctx); + if (ok != 1) throw std::runtime_error("MD5 finalisation failed"); + digest.resize(len); + return digest; } std::string mime_for(const std::string& name) { @@ -1007,17 +1018,30 @@ std::shared_ptr Client::upload(const std::string& path, std::stri } catch (const std::exception& e) { throw std::runtime_error(std::string("upload rejected for ") + path + ": " + e.what()); } - const Bytes data = read_file(path); - if (data.size() != file_size) throw std::runtime_error("file changed while reading: " + path); + std::ifstream in(path, std::ios::binary); + if (!in) throw std::runtime_error("cannot open file: " + path); out_name = std::filesystem::path(path).filename().string(); - out_size = static_cast(data.size()); - const std::int64_t file_id = random_i64(); + out_size = static_cast(file_size); const bool big = plan.big_file; + // The MD5 for inputFile needs a full pass over the file; inputFileBig does not carry one. + std::string md5_hex; + if (!big) { + md5_hex = to_hex(md5_of_stream(in)); + in.clear(); + in.seekg(0, std::ios::beg); + } + const std::int64_t file_id = random_i64(); const std::size_t parts = plan.part_count; for (std::size_t i = 0; i < parts; ++i) { - const std::size_t off = upload_part_offset(plan, static_cast(i)); - const std::size_t len = std::min(plan.part_size, data.size() - off); - Bytes chunk(data.begin() + static_cast(off), data.begin() + static_cast(off + len)); + const std::uint64_t off = upload_part_offset(plan, static_cast(i)); + const std::size_t len = static_cast(std::min(plan.part_size, file_size - off)); + Bytes chunk(len); + in.clear(); + in.seekg(static_cast(off), std::ios::beg); + in.read(reinterpret_cast(chunk.data()), static_cast(len)); + if (static_cast(in.gcount()) != len) { + throw std::runtime_error("file changed while uploading: " + path); + } if (big) { fn::upload_saveBigFilePart req; req.file_id = file_id; @@ -1037,7 +1061,7 @@ std::shared_ptr Client::upload(const std::string& path, std::stri if (big) { *file = tl::InputFile{ty::inputFileBig{file_id, static_cast(parts), out_name}}; } else { - *file = tl::InputFile{ty::inputFile{file_id, static_cast(parts), out_name, to_hex(mtproto::md5(data))}}; + *file = tl::InputFile{ty::inputFile{file_id, static_cast(parts), out_name, md5_hex}}; } return file; } @@ -1095,25 +1119,30 @@ std::int64_t Client::download_media(const Message& message, const std::string& p } // Data goes to ".part" and is renamed to path only after the size checks pass. A failed or // interrupted download leaves no partial file at the destination; the .part file is removed. + // The .part file is kept after a failure so the next call resumes from its last aligned chunk. It is + // trusted by size only: Telegram gives no hash for these files, so delete it if the file on the server changed. const std::string temp_path = path + ".part"; - struct TempFileGuard { - std::string file; - bool keep = false; - ~TempFileGuard() { - if (!keep) { - std::error_code ignored; - std::filesystem::remove(file, ignored); + std::int64_t offset = 0; + { + std::error_code size_ec; + const auto partial = std::filesystem::file_size(temp_path, size_ec); + if (!size_ec) { + const std::uint64_t resume = resume_offset(partial, static_cast(std::max(0, message.media.size)), + static_cast(kDownloadChunk)); + if (resume > 0) { + std::filesystem::resize_file(temp_path, resume, size_ec); + if (size_ec) throw std::runtime_error("cannot prepare resume of " + temp_path + ": " + size_ec.message()); + offset = static_cast(resume); } } - } guard{temp_path}; + } std::int64_t total = 0; { - std::ofstream out(temp_path, std::ios::binary | std::ios::trunc); + std::ofstream out(temp_path, std::ios::binary | (offset > 0 ? std::ios::app : std::ios::trunc)); if (!out) { throw std::runtime_error("cannot open for writing: " + temp_path); } - std::int64_t offset = 0; for (;;) { fn::upload_getFile req; req.location = location; @@ -1139,13 +1168,15 @@ std::int64_t Client::download_media(const Message& message, const std::string& p total = offset; } if (message.media.size > 0 && total != message.media.size) { + // Wrong size cannot be resumed from safely, so the partial file is discarded. + std::error_code ignored; + std::filesystem::remove(temp_path, ignored); throw std::runtime_error("download size mismatch: received " + std::to_string(total) + " bytes, expected " + std::to_string(message.media.size)); } std::error_code ec; std::filesystem::rename(temp_path, path, ec); if (ec) throw std::runtime_error("cannot move download into place: " + ec.message()); - guard.keep = true; if (progress) progress(total, message.media.size); return total; } diff --git a/src/transfer.cpp b/src/transfer.cpp index 730a562..ec8fe38 100644 --- a/src/transfer.cpp +++ b/src/transfer.cpp @@ -45,6 +45,12 @@ std::uint64_t download_chunk_count(std::uint64_t file_size, std::uint32_t limit) return (file_size + limit - 1) / limit; } +std::uint64_t resume_offset(std::uint64_t partial_size, std::uint64_t expected_size, std::uint32_t chunk) { + if (chunk == 0) throw std::invalid_argument("resume chunk must be positive"); + if (expected_size == 0 || partial_size > expected_size) return 0; + return partial_size / chunk * chunk; +} + std::string sanitize_remote_filename(std::string_view name, std::size_t max_len) { // Keep only the last component, treating both separators as separators. const std::size_t slash = name.find_last_of("/\\"); diff --git a/tests/mtproto/transfer_test.cpp b/tests/mtproto/transfer_test.cpp index 2ecfbbf..aa00b2d 100644 --- a/tests/mtproto/transfer_test.cpp +++ b/tests/mtproto/transfer_test.cpp @@ -105,3 +105,15 @@ TEST(FileNameSafety, CapsLengthAndKeepsExtension) { const std::string no_ext(300, 'b'); EXPECT_EQ(sanitize_remote_filename(no_ext, 100).size(), 100u); } + +TEST(TransferResume, OffsetIsAlignedAndBounded) { + const std::uint32_t chunk = 1024u * 1024u; + EXPECT_EQ(resume_offset(0, 5 * 1024 * 1024, chunk), 0u); + EXPECT_EQ(resume_offset(chunk - 1, 5 * 1024 * 1024, chunk), 0u); + EXPECT_EQ(resume_offset(chunk + 4096, 5 * 1024 * 1024, chunk), static_cast(chunk)); + EXPECT_EQ(resume_offset(3 * chunk, 5 * 1024 * 1024, chunk), 3u * chunk); + // Unknown expected size restarts, and a partial file larger than the target cannot be a prefix of it. + EXPECT_EQ(resume_offset(2 * chunk, 0, chunk), 0u); + EXPECT_EQ(resume_offset(6 * chunk, 5 * chunk, chunk), 0u); + EXPECT_THROW(resume_offset(1, 1, 0), std::invalid_argument); +} From c80bb978520b14cd60722a417bf7ad66ffa0f8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:37:38 +0000 Subject: [PATCH 04/25] tests: concurrent stop/wait stress test for sleep_cancellable Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- tests/CMakeLists.txt | 2 +- tests/mtproto/cancel_stress_test.cpp | 56 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/mtproto/cancel_stress_test.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9829153..d915479 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/channel_pts_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/cancel_stress_test.cpp b/tests/mtproto/cancel_stress_test.cpp new file mode 100644 index 0000000..d0b3387 --- /dev/null +++ b/tests/mtproto/cancel_stress_test.cpp @@ -0,0 +1,56 @@ +// Stress test: many threads wait on sleep_cancellable while another thread requests stop. +// Every waiter must return or throw OperationCancelled promptly; none may hang. +#include + +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/cancel.h" + +using cppgram::mtproto::OperationCancelled; +using cppgram::mtproto::sleep_cancellable; + +TEST(CancelStress, ConcurrentWaitersAllUnblockOnStop) { + constexpr int kRounds = 50; + constexpr int kWaiters = 16; + for (int round = 0; round < kRounds; ++round) { + std::stop_source source; + std::atomic cancelled{0}; + std::atomic finished{0}; + std::vector waiters; + waiters.reserve(kWaiters); + for (int i = 0; i < kWaiters; ++i) { + waiters.emplace_back([&, token = source.get_token()] { + try { + sleep_cancellable(std::chrono::milliseconds(60'000), token); + } catch (const OperationCancelled&) { + cancelled.fetch_add(1); + } + finished.fetch_add(1); + }); + } + // Give some waiters time to block, others stop before they start: both paths must finish. + std::this_thread::sleep_for(std::chrono::milliseconds(round % 5)); + source.request_stop(); + for (auto& t : waiters) t.join(); + EXPECT_EQ(finished.load(), kWaiters); + EXPECT_EQ(cancelled.load(), kWaiters); + } +} + +TEST(CancelStress, ShortWaitsCompleteWithoutStop) { + std::stop_source source; + std::vector waiters; + std::atomic ok{0}; + for (int i = 0; i < 8; ++i) { + waiters.emplace_back([&, token = source.get_token()] { + sleep_cancellable(std::chrono::milliseconds(5), token); + ok.fetch_add(1); + }); + } + for (auto& t : waiters) t.join(); + EXPECT_EQ(ok.load(), 8); +} From e9c5cb84f8a7b5fca6dc12f57e7e9a4977c55def Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:06:20 +0000 Subject: [PATCH 05/25] client: typed events for connection, authorization and RPC errors on_connection_state (Connected, Disconnected, Reconnecting), on_auth_state and on_rpc_error. Every RPC goes through call_rpc, which reports Telegram errors before rethrowing. Handler exceptions are isolated and reported via on_error. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- include/cppgram/client.h | 30 +++++++- src/client.cpp | 106 ++++++++++++++++++++------- src/client_account_security.cpp | 2 +- src/client_account_settings.cpp | 16 ++-- src/client_batch5.cpp | 8 +- src/client_batch6.cpp | 6 +- src/client_batch7.cpp | 20 ++--- src/client_bot.cpp | 6 +- src/client_bot_admin.cpp | 6 +- src/client_business_and_stealth.cpp | 14 ++-- src/client_business_bots.cpp | 4 +- src/client_chat_extras.cpp | 8 +- src/client_chats.cpp | 10 +-- src/client_checklists.cpp | 6 +- src/client_contacts_and_blocks.cpp | 8 +- src/client_extra.cpp | 26 +++---- src/client_folder_invites.cpp | 18 ++--- src/client_folders.cpp | 18 ++--- src/client_forum.cpp | 12 +-- src/client_forum_and_stars.cpp | 10 +-- src/client_inline_edit.cpp | 2 +- src/client_invite_check.cpp | 2 +- src/client_invites.cpp | 10 +-- src/client_login.cpp | 10 +-- src/client_markup.cpp | 6 +- src/client_media.cpp | 10 +-- src/client_message.cpp | 2 +- src/client_more.cpp | 52 ++++++------- src/client_own_star_gifts.cpp | 8 +- src/client_payment_receipts.cpp | 2 +- src/client_poll_stop.cpp | 2 +- src/client_profile.cpp | 14 ++-- src/client_send.cpp | 12 +-- src/client_session.cpp | 22 +++--- src/client_star_gift_collections.cpp | 4 +- src/client_star_gift_listing.cpp | 8 +- src/client_stickers.cpp | 26 +++---- src/client_stories_messages.cpp | 22 +++--- src/client_story_posting.cpp | 2 +- src/client_story_reactions.cpp | 8 +- src/client_text.cpp | 4 +- 41 files changed, 321 insertions(+), 241 deletions(-) diff --git a/include/cppgram/client.h b/include/cppgram/client.h index 3574588..7b59348 100644 --- a/include/cppgram/client.h +++ b/include/cppgram/client.h @@ -88,6 +88,12 @@ class Client { using JoinRequestHandler = std::function; using DeletedMessagesHandler = std::function; using DownloadProgress = std::function; + // Connection lifecycle, fired by connect(), disconnect() and the reconnect path. + enum class ConnectionEvent { Connected, Disconnected, Reconnecting }; + using ConnectionHandler = std::function; + using AuthStateHandler = std::function; + // Telegram RPC errors seen by any request, with the bare error name (for example FLOOD_WAIT_30). + using RpcErrorHandler = std::function; // stage is the handler kind that threw (for example "message handler"), message is the exception text. using ErrorHandler = std::function; @@ -119,6 +125,11 @@ class Client { void on_message(Filter filter, MessageHandler handler, int group = 0); // Called whenever a handler throws. Without one, the error is printed to stderr. void on_error(ErrorHandler handler); + // Typed event handlers. A handler that throws is reported through on_error and does not stop the others. + // Handlers run on the thread that caused the event (the update loop, or the caller of connect/login). + void on_connection_state(ConnectionHandler handler); + void on_auth_state(AuthStateHandler handler); + void on_rpc_error(RpcErrorHandler handler); void on_callback_query(CallbackHandler handler); // Filtered overloads: the handler runs only when the filter matches. void on_callback_query(CallbackFilter filter, CallbackHandler handler) { @@ -146,7 +157,7 @@ class Client { template typename Request::Result invoke(const Request& request) { require_session(); - return session_->call(request); + return call_rpc(request); } // ---- peers @@ -743,6 +754,23 @@ class Client { }; std::vector message_handlers_; std::vector error_handlers_; + std::vector connection_handlers_; + std::vector auth_handlers_; + std::vector rpc_error_handlers_; + // Single entry point for every RPC: reports Telegram errors to on_rpc_error, then rethrows. + template + typename Request::Result call_rpc(const Request& request) { + try { + return session_->call(request); + } catch (const mtproto::RpcError& e) { + notify_rpc_error(e.code(), e.rpc_message()); + throw; + } + } + void notify_rpc_error(std::int32_t code, const std::string& name); + void notify_connection(ConnectionEvent event); + // Sets the authorization state and notifies on_auth_state when it changes. + void set_auth_state(AuthState next); void report_handler_error(const std::string& stage, const std::exception& e); std::vector callback_handlers_; std::vector raw_update_handlers_; diff --git a/src/client.cpp b/src/client.cpp index 8a410e5..a1b4bf7 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -146,7 +146,7 @@ void Client::require_session() const { void Client::connect() { disconnect(); - auth_state_ = AuthState::Connecting; + set_auth_state(AuthState::Connecting); const bool debug = options_.debug; connection_ = std::make_unique(mtproto::TransportKind::Intermediate); debug_log(debug, "tcp: connecting to " + options_.dc_ip + ":" + std::to_string(options_.dc_port) + @@ -231,7 +231,7 @@ void Client::connect() { init.query.app_version = options_.app_version; init.query.system_lang_code = options_.lang_code; init.query.lang_code = options_.lang_code; - const tl::Config cfg = session_->call(init); + const tl::Config cfg = call_rpc(init); dc_endpoints_.clear(); if (const auto* c = std::get_if(&cfg.value)) { for (const auto& opt : c->dc_options) { @@ -242,13 +242,16 @@ void Client::connect() { } } connection_->set_receive_timeout(0); - auth_state_ = AuthState::Unauthenticated; // authorization is unknown until probe_session() + set_auth_state(AuthState::Unauthenticated); // authorization is unknown until probe_session() + notify_connection(ConnectionEvent::Connected); debug_log(debug, "session: initConnection done"); } void Client::disconnect() { - auth_state_ = AuthState::Disconnected; + const bool was_connected = session_ != nullptr; + set_auth_state(AuthState::Disconnected); session_.reset(); + if (was_connected) notify_connection(ConnectionEvent::Disconnected); if (connection_) { connection_->close(); connection_.reset(); @@ -262,7 +265,7 @@ void Client::login_bot(const std::string& bot_token) { req.api_id = options_.api_id; req.api_hash = options_.api_hash; req.bot_auth_token = bot_token; - session_->call(req); + call_rpc(req); if (auth_state_ == AuthState::Unauthenticated) apply_auth_event(AuthEvent::SessionRestored); sync_updates_state(); } @@ -277,7 +280,7 @@ User Client::get_me() { require_session(); fn::users_getUsers req; req.id = {std::make_shared(tl::InputUser{ty::inputUserSelf{}})}; - const auto users = session_->call(req); + const auto users = call_rpc(req); cache_users_chats(users, {}); for (const auto& u : users) { if (u) { @@ -299,6 +302,54 @@ void Client::on_error(ErrorHandler handler) { error_handlers_.push_back(std::move(handler)); } +void Client::on_connection_state(ConnectionHandler handler) { + connection_handlers_.push_back(std::move(handler)); +} + +void Client::on_auth_state(AuthStateHandler handler) { + auth_handlers_.push_back(std::move(handler)); +} + +void Client::on_rpc_error(RpcErrorHandler handler) { + rpc_error_handlers_.push_back(std::move(handler)); +} + +void Client::notify_rpc_error(std::int32_t code, const std::string& name) { + // Copy the list: a handler may register another handler while we iterate. + const auto handlers = rpc_error_handlers_; + for (const auto& h : handlers) { + try { + h(*this, code, name); + } catch (const std::exception& e) { + report_handler_error("rpc error handler", e); + } + } +} + +void Client::notify_connection(ConnectionEvent event) { + const auto handlers = connection_handlers_; + for (const auto& h : handlers) { + try { + h(*this, event); + } catch (const std::exception& e) { + report_handler_error("connection handler", e); + } + } +} + +void Client::set_auth_state(AuthState next) { + if (next == auth_state_) return; + auth_state_ = next; + const auto handlers = auth_handlers_; + for (const auto& h : handlers) { + try { + h(*this, next); + } catch (const std::exception& e) { + report_handler_error("auth state handler", e); + } + } +} + void Client::report_handler_error(const std::string& stage, const std::exception& e) { if (error_handlers_.empty()) { std::cerr << "cppgram: " << stage << " failed: " << e.what() << "\n"; @@ -649,6 +700,7 @@ void Client::ping() { void Client::reconnect_after_drop() { // connect() resets the authorization state, so remember it and check the key again afterwards. const bool was_authorized = auth_state_ == AuthState::Authorized; + notify_connection(ConnectionEvent::Reconnecting); for (;;) { if (!reconnect_budget_.record_failure()) { throw mtproto::ConnectionError("gave up reconnecting after " + std::to_string(options_.max_reconnects) + @@ -785,7 +837,7 @@ PeerRef Client::resolve(const std::string& reference) { fn::contacts_resolveUsername req; req.username = name; - const tl::contacts_ResolvedPeer resolved = session_->call(req); + const tl::contacts_ResolvedPeer resolved = call_rpc(req); const auto* rp = std::get_if(&resolved.value); if (rp == nullptr || !rp->peer) { throw std::runtime_error("username not found: " + name); @@ -876,7 +928,7 @@ Message Client::send_message(const PeerRef& target, const std::string& text, req.reply_to = std::make_shared(tl::InputReplyTo{ty::inputReplyToMessage{*reply_to_id}}); } req.effect = effect_id; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, text, 0)) { return *msg; } @@ -889,7 +941,7 @@ Message Client::edit_message(const PeerRef& target, std::int32_t message_id, con req.peer = std::make_shared(input_peer(target)); req.id = message_id; req.message = text; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, text, message_id)) { return *msg; } @@ -902,13 +954,13 @@ void Client::delete_messages(const PeerRef& target, const std::vector(tl::InputChannel{ty::inputChannel{target.id, target.access_hash}}); req.id = message_ids; - session_->call(req); + call_rpc(req); return; } fn::messages_deleteMessages req; req.revoke = revoke; req.id = message_ids; - session_->call(req); + call_rpc(req); } std::vector Client::get_messages(const PeerRef& target, const std::vector& message_ids) { @@ -919,13 +971,13 @@ std::vector Client::get_messages(const PeerRef& target, const std::vect for (const auto id : message_ids) { req.id.push_back(std::make_shared(tl::InputMessage{ty::inputMessageID{id}})); } - return collect_messages(session_->call(req)); + return collect_messages(call_rpc(req)); } fn::messages_getMessages req; for (const auto id : message_ids) { req.id.push_back(std::make_shared(tl::InputMessage{ty::inputMessageID{id}})); } - return collect_messages(session_->call(req)); + return collect_messages(call_rpc(req)); } std::vector Client::get_history(const PeerRef& target, std::int32_t limit, std::int32_t offset_id) { @@ -934,7 +986,7 @@ std::vector Client::get_history(const PeerRef& target, std::int32_t lim req.peer = std::make_shared(input_peer(target)); req.offset_id = offset_id; req.limit = limit; - return collect_messages(session_->call(req)); + return collect_messages(call_rpc(req)); } void Client::send_chat_action(const PeerRef& target, ChatAction action) { @@ -950,7 +1002,7 @@ void Client::send_chat_action(const PeerRef& target, ChatAction action) { case ChatAction::RecordVoice: req.action = std::make_shared(tl::SendMessageAction{ty::sendMessageRecordAudioAction{}}); break; case ChatAction::RecordVideo: req.action = std::make_shared(tl::SendMessageAction{ty::sendMessageRecordRoundAction{}}); break; } - session_->call(req); + call_rpc(req); } void Client::pin_message(const PeerRef& target, std::int32_t message_id, bool silent) { @@ -960,7 +1012,7 @@ void Client::pin_message(const PeerRef& target, std::int32_t message_id, bool si req.id = message_id; req.silent = silent; req.unpin = false; - session_->call(req); + call_rpc(req); } void Client::unpin_message(const PeerRef& target, std::int32_t message_id) { @@ -969,7 +1021,7 @@ void Client::unpin_message(const PeerRef& target, std::int32_t message_id) { req.peer = std::make_shared(input_peer(target)); req.id = message_id; req.unpin = true; - session_->call(req); + call_rpc(req); } std::vector Client::forward_messages(const PeerRef& from, const PeerRef& to, @@ -982,7 +1034,7 @@ std::vector Client::forward_messages(const PeerRef& from, const PeerRef for (std::size_t i = 0; i < message_ids.size(); ++i) { req.random_id.push_back(random_i64()); } - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); std::vector out; if (const auto* u = std::get_if(&updates.value)) { for (const auto& up : u->updates) { @@ -1004,7 +1056,7 @@ void Client::answer_callback_query(std::int64_t query_id, const std::string& tex req.alert = alert; if (!text.empty()) req.message = text; if (!url.empty()) req.url = url; - session_->call(req); + call_rpc(req); } std::shared_ptr Client::upload(const std::string& path, std::string& out_name, std::int64_t& out_size) { @@ -1048,13 +1100,13 @@ std::shared_ptr Client::upload(const std::string& path, std::stri req.file_part = static_cast(i); req.file_total_parts = static_cast(parts); req.bytes = std::move(chunk); - session_->call(req); + call_rpc(req); } else { fn::upload_saveFilePart req; req.file_id = file_id; req.file_part = static_cast(i); req.bytes = std::move(chunk); - session_->call(req); + call_rpc(req); } } auto file = std::make_shared(); @@ -1073,7 +1125,7 @@ Message Client::send_media(const PeerRef& target, std::shared_ptrcall(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, caption, 0)) { return *msg; } @@ -1149,7 +1201,7 @@ std::int64_t Client::download_media(const Message& message, const std::string& p req.offset = offset; req.limit = kDownloadChunk; req.cdn_supported = true; - const tl::upload_File file = session_->call(req); + const tl::upload_File file = call_rpc(req); const auto* f = std::get_if(&file.value); if (f == nullptr) { const auto* redirect = std::get_if(&file.value); @@ -1224,7 +1276,7 @@ std::int64_t Client::download_via_cdn(const ty::upload_fileCdnRedirect& redirect fn::upload_getCdnFileHashes hreq; hreq.file_token = redirect.file_token; hreq.offset = p; - const std::vector> fetched = session_->call(hreq); + const std::vector> fetched = call_rpc(hreq); for (const auto& h : fetched) { if (h) if (const auto* fh = std::get_if(&h->value)) hashes[fh->offset] = {fh->limit, fh->hash}; } @@ -1271,21 +1323,21 @@ void Client::join_chat_by_invite(const std::string& invite_hash) { require_session(); fn::messages_importChatInvite req; req.hash = invite_hash; - session_->call(req); + call_rpc(req); } void Client::join_channel(const PeerRef& channel) { require_session(); fn::channels_joinChannel req; req.channel = std::make_shared(tl::InputChannel{ty::inputChannel{channel.id, channel.access_hash}}); - session_->call(req); + call_rpc(req); } void Client::leave_channel(const PeerRef& channel) { require_session(); fn::channels_leaveChannel req; req.channel = std::make_shared(tl::InputChannel{ty::inputChannel{channel.id, channel.access_hash}}); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_account_security.cpp b/src/client_account_security.cpp index 875235a..765835f 100644 --- a/src/client_account_security.cpp +++ b/src/client_account_security.cpp @@ -11,7 +11,7 @@ namespace tl = mtproto::tl; CloudPasswordStatus Client::get_cloud_password_status() { require_session(); fn::account_getPassword req; - const tl::account_Password result = session_->call(req); + const tl::account_Password result = call_rpc(req); const auto* pw = std::get_if(&result.value); if (pw == nullptr) throw std::runtime_error("get_cloud_password_status: unexpected result"); diff --git a/src/client_account_settings.cpp b/src/client_account_settings.cpp index 01e3866..07d0261 100644 --- a/src/client_account_settings.cpp +++ b/src/client_account_settings.cpp @@ -15,7 +15,7 @@ namespace tl = mtproto::tl; std::int32_t Client::get_account_ttl_days() { require_session(); fn::account_getAccountTTL req; - const tl::AccountDaysTTL res = session_->call(req); + const tl::AccountDaysTTL res = call_rpc(req); const auto* d = std::get_if(&res.value); if (d == nullptr) throw std::runtime_error("get_account_ttl_days: unexpected response"); return d->days; @@ -25,20 +25,20 @@ void Client::set_account_ttl_days(std::int32_t days) { require_session(); fn::account_setAccountTTL req; req.ttl = std::make_shared(tl::AccountDaysTTL{ty::accountDaysTTL{days}}); - session_->call(req); + call_rpc(req); } bool Client::check_username(const std::string& username) { require_session(); fn::account_checkUsername req; req.username = username; - return session_->call(req); + return call_rpc(req); } GlobalPrivacyInfo Client::get_global_privacy_settings() { require_session(); fn::account_getGlobalPrivacySettings req; - const tl::GlobalPrivacySettings res = session_->call(req); + const tl::GlobalPrivacySettings res = call_rpc(req); const auto* g = std::get_if(&res.value); if (g == nullptr) throw std::runtime_error("get_global_privacy_settings: unexpected response"); GlobalPrivacyInfo out; @@ -61,7 +61,7 @@ void Client::set_archive_and_mute_new_noncontact(bool enabled) { next.display_gifts_button = current.display_gifts_button; fn::account_setGlobalPrivacySettings req; req.settings = std::make_shared(tl::GlobalPrivacySettings{next}); - session_->call(req); + call_rpc(req); } // ---- phase 10: emoji and recent stickers @@ -70,14 +70,14 @@ void Client::clear_recent_stickers(bool attached) { require_session(); fn::messages_clearRecentStickers req; req.attached = attached; - session_->call(req); + call_rpc(req); } std::vector Client::get_default_emoji_status_ids() { require_session(); fn::account_getDefaultEmojiStatuses req; req.hash = 0; - const tl::account_EmojiStatuses res = session_->call(req); + const tl::account_EmojiStatuses res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -92,7 +92,7 @@ std::vector Client::get_custom_emoji_document_ids(const std::vecto require_session(); fn::messages_getCustomEmojiDocuments req; req.document_id = ids; - const auto docs = session_->call(req); + const auto docs = call_rpc(req); std::vector out; for (const auto& d : docs) { if (!d) continue; diff --git a/src/client_batch5.cpp b/src/client_batch5.cpp index e16698b..3053675 100644 --- a/src/client_batch5.cpp +++ b/src/client_batch5.cpp @@ -31,7 +31,7 @@ bool Client::add_recent_sticker(const DocumentRef& sticker) { fn::messages_saveRecentSticker req; req.id = input_document_of(sticker); req.unsave = false; - return session_->call(req); + return call_rpc(req); } bool Client::remove_recent_sticker(const DocumentRef& sticker) { @@ -39,7 +39,7 @@ bool Client::remove_recent_sticker(const DocumentRef& sticker) { fn::messages_saveRecentSticker req; req.id = input_document_of(sticker); req.unsave = true; - return session_->call(req); + return call_rpc(req); } void Client::apply_gift_code(const std::string& slug) { @@ -49,7 +49,7 @@ void Client::apply_gift_code(const std::string& slug) { } fn::payments_applyGiftCode req; req.slug = slug; - session_->call(req); + call_rpc(req); } void Client::apply_boost(const PeerRef& chat, const std::vector& slots) { @@ -60,7 +60,7 @@ void Client::apply_boost(const PeerRef& chat, const std::vector& s fn::premium_applyBoost req; req.slots = slots; req.peer = std::make_shared(input_peer(chat)); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_batch6.cpp b/src/client_batch6.cpp index a3e556e..28c2e64 100644 --- a/src/client_batch6.cpp +++ b/src/client_batch6.cpp @@ -15,7 +15,7 @@ void Client::unpin_all_messages(const PeerRef& chat, std::optional fn::messages_unpinAllMessages req; req.peer = std::make_shared(input_peer(chat)); req.top_msg_id = top_msg_id; - session_->call(req); + call_rpc(req); } void Client::set_contact_note(const PeerRef& user, const std::string& note) { @@ -33,7 +33,7 @@ void Client::set_contact_note(const PeerRef& user, const std::string& note) { req.id = std::make_shared(mtproto::tl::InputUser{ty::inputUser{p.id, p.access_hash}}); req.note = std::make_shared( mtproto::tl::TextWithEntities{ty::textWithEntities{note, {}}}); - session_->call(req); + call_rpc(req); } bool Client::accept_terms_of_service(const std::string& terms_id) { @@ -43,7 +43,7 @@ bool Client::accept_terms_of_service(const std::string& terms_id) { } fn::help_acceptTermsOfService req; req.id = std::make_shared(mtproto::tl::DataJSON{ty::dataJSON{terms_id}}); - return session_->call(req); + return call_rpc(req); } } // namespace cppgram diff --git a/src/client_batch7.cpp b/src/client_batch7.cpp index 8656ee4..775cfbf 100644 --- a/src/client_batch7.cpp +++ b/src/client_batch7.cpp @@ -25,7 +25,7 @@ std::shared_ptr channel_ptr(const PeerRef& p) { std::int32_t Client::get_account_ttl() { require_session(); fn::account_getAccountTTL req; - const tl::AccountDaysTTL res = session_->call(req); + const tl::AccountDaysTTL res = call_rpc(req); const auto* d = std::get_if(&res.value); if (d == nullptr) throw std::runtime_error("get_account_ttl: unexpected response"); return d->days; @@ -36,7 +36,7 @@ bool Client::set_account_ttl(std::int32_t days) { if (days < 1) throw std::invalid_argument("set_account_ttl: days must be at least 1"); fn::account_setAccountTTL req; req.ttl = std::make_shared(tl::AccountDaysTTL{ty::accountDaysTTL{days}}); - return session_->call(req); + return call_rpc(req); } void Client::set_chat_ttl(const PeerRef& chat, std::int32_t period_seconds) { @@ -45,7 +45,7 @@ void Client::set_chat_ttl(const PeerRef& chat, std::int32_t period_seconds) { fn::messages_setHistoryTTL req; req.peer = std::make_shared(input_peer(chat)); req.period = period_seconds; - session_->call(req); + call_rpc(req); } bool Client::set_chat_username(const PeerRef& channel, const std::string& username) { @@ -53,7 +53,7 @@ bool Client::set_chat_username(const PeerRef& channel, const std::string& userna fn::channels_updateUsername req; req.channel = channel_ptr(channel); req.username = username; - return session_->call(req); + return call_rpc(req); } bool Client::set_chat_discussion_group(const PeerRef& broadcast, const PeerRef& group) { @@ -61,7 +61,7 @@ bool Client::set_chat_discussion_group(const PeerRef& broadcast, const PeerRef& fn::channels_setDiscussionGroup req; req.broadcast = channel_ptr(broadcast); req.group = channel_ptr(group); - return session_->call(req); + return call_rpc(req); } void Client::toggle_join_to_send(const PeerRef& channel, bool enabled) { @@ -69,7 +69,7 @@ void Client::toggle_join_to_send(const PeerRef& channel, bool enabled) { fn::channels_toggleJoinToSend req; req.channel = channel_ptr(channel); req.enabled = enabled; - session_->call(req); + call_rpc(req); } void Client::toggle_forum_topics(const PeerRef& channel, bool enabled, bool tabs) { @@ -78,7 +78,7 @@ void Client::toggle_forum_topics(const PeerRef& channel, bool enabled, bool tabs req.channel = channel_ptr(channel); req.enabled = enabled; req.tabs = tabs; - session_->call(req); + call_rpc(req); } void Client::set_chat_accent_color(const PeerRef& channel, std::int32_t color) { @@ -88,7 +88,7 @@ void Client::set_chat_accent_color(const PeerRef& channel, std::int32_t color) { req.for_profile = false; req.channel = channel_ptr(channel); req.color = color; - session_->call(req); + call_rpc(req); } void Client::set_chat_profile_accent_color(const PeerRef& channel, std::int32_t color) { @@ -98,14 +98,14 @@ void Client::set_chat_profile_accent_color(const PeerRef& channel, std::int32_t req.for_profile = true; req.channel = channel_ptr(channel); req.color = color; - session_->call(req); + call_rpc(req); } bool Client::toggle_folder_tags(bool enabled) { require_session(); fn::messages_toggleDialogFilterTags req; req.enabled = enabled; - return session_->call(req); + return call_rpc(req); } } // namespace cppgram diff --git a/src/client_bot.cpp b/src/client_bot.cpp index 6122841..3a9fd03 100644 --- a/src/client_bot.cpp +++ b/src/client_bot.cpp @@ -40,7 +40,7 @@ void Client::set_game_score(const PeerRef& chat, std::int32_t message_id, const req.score = score; req.force = force; req.edit_message = edit_message; - session_->call(req); + call_rpc(req); } std::vector Client::get_game_high_scores(const PeerRef& chat, std::int32_t message_id, const PeerRef& user) { @@ -49,7 +49,7 @@ std::vector Client::get_game_high_scores(const PeerRef& chat, std::in req.peer = std::make_shared(input_peer(chat)); req.id = message_id; req.user_id = input_user_ptr(user); - const tl::messages_HighScores result = session_->call(req); + const tl::messages_HighScores result = call_rpc(req); std::vector out; const auto* hs = std::get_if(&result.value); if (hs == nullptr) return out; @@ -112,7 +112,7 @@ void Client::answer_inline_results(std::int64_t query_id, const std::vectorcall(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_bot_admin.cpp b/src/client_bot_admin.cpp index 1e20beb..b7964a2 100644 --- a/src/client_bot_admin.cpp +++ b/src/client_bot_admin.cpp @@ -11,7 +11,7 @@ bool Client::reset_bot_commands(const std::string& lang_code) { fn::bots_resetBotCommands req; req.scope = std::make_shared(tl::BotCommandScope{ty::botCommandScopeDefault{}}); req.lang_code = lang_code; - return session_->call(req); + return call_rpc(req); } bool Client::set_bot_default_admin_rights(const AdminRights& rights, bool broadcast) { @@ -30,11 +30,11 @@ bool Client::set_bot_default_admin_rights(const AdminRights& rights, bool broadc if (broadcast) { fn::bots_setBotBroadcastDefaultAdminRights req; req.admin_rights = admin_rights; - return session_->call(req); + return call_rpc(req); } fn::bots_setBotGroupDefaultAdminRights req; req.admin_rights = admin_rights; - return session_->call(req); + return call_rpc(req); } } // namespace cppgram diff --git a/src/client_business_and_stealth.cpp b/src/client_business_and_stealth.cpp index 346942c..ca96bf7 100644 --- a/src/client_business_and_stealth.cpp +++ b/src/client_business_and_stealth.cpp @@ -36,14 +36,14 @@ void Client::set_business_away_message(std::int32_t shortcut_id, bool offline_on m.recipients = all_recipients(); fn::account_updateBusinessAwayMessage req; req.message = std::make_shared(tl::InputBusinessAwayMessage{m}); - session_->call(req); + call_rpc(req); } void Client::clear_business_away_message() { require_session(); fn::account_updateBusinessAwayMessage req; req.message = std::nullopt; - session_->call(req); + call_rpc(req); } void Client::set_business_greeting_message(std::int32_t shortcut_id, std::int32_t no_activity_days) { @@ -54,14 +54,14 @@ void Client::set_business_greeting_message(std::int32_t shortcut_id, std::int32_ m.no_activity_days = no_activity_days; fn::account_updateBusinessGreetingMessage req; req.message = std::make_shared(tl::InputBusinessGreetingMessage{m}); - session_->call(req); + call_rpc(req); } void Client::clear_business_greeting_message() { require_session(); fn::account_updateBusinessGreetingMessage req; req.message = std::nullopt; - session_->call(req); + call_rpc(req); } // ---- phase 14: stealth mode and sticker set edits @@ -71,7 +71,7 @@ void Client::enable_stealth_mode(bool past, bool future) { fn::stories_activateStealthMode req; req.past = past; req.future = future; - session_->call(req); + call_rpc(req); } namespace { @@ -96,7 +96,7 @@ void Client::change_sticker_position(const std::string& short_name, std::int64_t fn::stickers_changeStickerPosition req; req.sticker = input_doc_of(set.stickers, document_id); req.position = position; - session_->call(req); + call_rpc(req); } void Client::remove_sticker_from_set(const std::string& short_name, std::int64_t document_id) { @@ -104,7 +104,7 @@ void Client::remove_sticker_from_set(const std::string& short_name, std::int64_t const StickerSetContent set = get_sticker_set(short_name); fn::stickers_removeStickerFromSet req; req.sticker = input_doc_of(set.stickers, document_id); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_business_bots.cpp b/src/client_business_bots.cpp index 0853bd6..7618f9b 100644 --- a/src/client_business_bots.cpp +++ b/src/client_business_bots.cpp @@ -9,7 +9,7 @@ namespace tl = mtproto::tl; std::vector Client::get_connected_bots() { require_session(); fn::account_getConnectedBots req; - const tl::account_ConnectedBots result = session_->call(req); + const tl::account_ConnectedBots result = call_rpc(req); const auto* list = std::get_if(&result.value); std::vector out; if (list == nullptr) return out; @@ -34,7 +34,7 @@ void Client::disconnect_connected_bot(const PeerRef& bot) { req.bot = input_user_ptr(bot); req.recipients = std::make_shared( tl::InputBusinessBotRecipients{ty::inputBusinessBotRecipients{}}); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_chat_extras.cpp b/src/client_chat_extras.cpp index 9386c3b..011a2d4 100644 --- a/src/client_chat_extras.cpp +++ b/src/client_chat_extras.cpp @@ -25,7 +25,7 @@ std::int32_t Client::get_chat_online_count(const PeerRef& chat) { require_session(); fn::messages_getOnlines req; req.peer = std::make_shared(input_peer(chat)); - const tl::ChatOnlines res = session_->call(req); + const tl::ChatOnlines res = call_rpc(req); const auto* o = std::get_if(&res.value); return o ? o->onlines : 0; } @@ -41,7 +41,7 @@ std::int32_t Client::get_chat_history_count(const PeerRef& chat) { req.max_id = 0; req.min_id = 0; req.hash = 0; - const tl::messages_Messages res = session_->call(req); + const tl::messages_Messages res = call_rpc(req); if (const auto* slice = std::get_if(&res.value)) return slice->count; if (const auto* all = std::get_if(&res.value)) { return static_cast(all->messages.size()); @@ -57,14 +57,14 @@ void Client::delete_chat_photo(const PeerRef& chat) { fn::channels_editPhoto req; req.channel = channel_ref(chat); req.photo = std::make_shared(tl::InputChatPhoto{empty}); - session_->call(req); + call_rpc(req); return; } if (chat.kind != PeerKind::Chat) throw std::invalid_argument("delete_chat_photo expects a group or channel"); fn::messages_editChatPhoto req; req.chat_id = chat.id; req.photo = std::make_shared(tl::InputChatPhoto{empty}); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_chats.cpp b/src/client_chats.cpp index c11b41d..541f27b 100644 --- a/src/client_chats.cpp +++ b/src/client_chats.cpp @@ -25,7 +25,7 @@ void Client::mark_unread(const PeerRef& chat, bool unread) { ty::inputDialogPeer dp; dp.peer = std::make_shared(input_peer(chat)); req.peer = std::make_shared(tl::InputDialogPeer{dp}); - session_->call(req); + call_rpc(req); } void Client::archive_chat(const PeerRef& chat, bool archived) { @@ -35,7 +35,7 @@ void Client::archive_chat(const PeerRef& chat, bool archived) { fp.peer = std::make_shared(input_peer(chat)); fp.folder_id = archived ? 1 : 0; req.folder_peers.push_back(std::make_shared(tl::InputFolderPeer{fp})); - session_->call(req); + call_rpc(req); } void Client::set_slow_mode(const PeerRef& channel, std::int32_t seconds) { @@ -44,7 +44,7 @@ void Client::set_slow_mode(const PeerRef& channel, std::int32_t seconds) { fn::channels_toggleSlowMode req; req.channel = channel_of(channel); req.seconds = seconds; - session_->call(req); + call_rpc(req); } void Client::set_chat_permissions(const PeerRef& chat, const Restrictions& r) { @@ -61,7 +61,7 @@ void Client::set_chat_permissions(const PeerRef& chat, const Restrictions& r) { fn::messages_editChatDefaultBannedRights req; req.peer = std::make_shared(input_peer(chat)); req.banned_rights = std::make_shared(tl::ChatBannedRights{rights}); - session_->call(req); + call_rpc(req); } void Client::enable_forum(const PeerRef& channel, bool enabled) { @@ -70,7 +70,7 @@ void Client::enable_forum(const PeerRef& channel, bool enabled) { req.channel = channel_of(channel); req.enabled = enabled; req.tabs = true; - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_checklists.cpp b/src/client_checklists.cpp index 8f90990..3a7d161 100644 --- a/src/client_checklists.cpp +++ b/src/client_checklists.cpp @@ -51,7 +51,7 @@ void Client::send_todo_list(const PeerRef& chat, const std::string& title, const req.peer = std::make_shared(input_peer(chat)); req.media = std::make_shared(tl::InputMedia{media}); req.random_id = new_random_id(); - session_->call(req); + call_rpc(req); } void Client::toggle_todo_items(const PeerRef& chat, std::int32_t msg_id, const std::vector& completed, @@ -62,7 +62,7 @@ void Client::toggle_todo_items(const PeerRef& chat, std::int32_t msg_id, const s req.msg_id = msg_id; req.completed = completed; req.incompleted = incompleted; - session_->call(req); + call_rpc(req); } void Client::append_todo_items(const PeerRef& chat, std::int32_t msg_id, const std::vector& items) { @@ -71,7 +71,7 @@ void Client::append_todo_items(const PeerRef& chat, std::int32_t msg_id, const s req.peer = std::make_shared(input_peer(chat)); req.msg_id = msg_id; req.list = checklist_items(items); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_contacts_and_blocks.cpp b/src/client_contacts_and_blocks.cpp index f797aa8..f586fc7 100644 --- a/src/client_contacts_and_blocks.cpp +++ b/src/client_contacts_and_blocks.cpp @@ -16,7 +16,7 @@ std::vector Client::get_quick_replies() { require_session(); fn::messages_getQuickReplies req; req.hash = 0; - const tl::messages_QuickReplies res = session_->call(req); + const tl::messages_QuickReplies res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -39,7 +39,7 @@ std::vector Client::get_blocked_users(std::int32_t limit) { req.my_stories_from = false; req.offset = 0; req.limit = limit; - const tl::contacts_Blocked res = session_->call(req); + const tl::contacts_Blocked res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -60,7 +60,7 @@ std::int32_t Client::get_contacts_count() { require_session(); fn::contacts_getContacts req; req.hash = 0; - const tl::contacts_Contacts res = session_->call(req); + const tl::contacts_Contacts res = call_rpc(req); const auto* c = std::get_if(&res.value); if (c == nullptr) return 0; return static_cast(c->contacts.size()); @@ -75,7 +75,7 @@ std::int32_t Client::get_dialogs_count() { req.offset_peer = std::make_shared(tl::InputPeer{ty::inputPeerEmpty{}}); req.limit = 1; req.hash = 0; - const tl::messages_Dialogs res = session_->call(req); + const tl::messages_Dialogs res = call_rpc(req); if (const auto* slice = std::get_if(&res.value)) return slice->count; if (const auto* all = std::get_if(&res.value)) { return static_cast(all->dialogs.size()); diff --git a/src/client_extra.cpp b/src/client_extra.cpp index 7c6953c..7ebfbbb 100644 --- a/src/client_extra.cpp +++ b/src/client_extra.cpp @@ -38,7 +38,7 @@ std::optional Client::copy_message(const PeerRef& from, const PeerRef& req.to_peer = std::make_shared(input_peer(to)); req.id = {message_id}; req.random_id = {next_random_id()}; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); const std::vector>* list = nullptr; if (const auto* u = std::get_if(&updates.value)) { @@ -67,7 +67,7 @@ std::vector Client::search_messages(const PeerRef& chat, const std::str req.q = query; req.filter = std::make_shared(tl::MessagesFilter{ty::inputMessagesFilterEmpty{}}); req.limit = limit; - const tl::messages_Messages result = session_->call(req); + const tl::messages_Messages result = call_rpc(req); std::vector out; std::visit( [&](const auto& v) { @@ -92,7 +92,7 @@ void Client::send_reaction(const PeerRef& target, std::int32_t message_id, const req.reaction = std::vector>{ std::make_shared(tl::Reaction{ty::reactionEmoji{emoji}})}; } - session_->call(req); + call_rpc(req); } void Client::mark_read(const PeerRef& target, std::int32_t max_id) { @@ -101,13 +101,13 @@ void Client::mark_read(const PeerRef& target, std::int32_t max_id) { fn::channels_readHistory req; req.channel = input_channel_of(target); req.max_id = max_id; - session_->call(req); + call_rpc(req); return; } fn::messages_readHistory req; req.peer = std::make_shared(input_peer(target)); req.max_id = max_id; - session_->call(req); + call_rpc(req); } std::vector Client::get_dialogs_last_messages(std::int32_t limit) { @@ -115,7 +115,7 @@ std::vector Client::get_dialogs_last_messages(std::int32_t limit) { fn::messages_getDialogs req; req.offset_peer = std::make_shared(tl::InputPeer{ty::inputPeerEmpty{}}); req.limit = limit; - const tl::messages_Dialogs result = session_->call(req); + const tl::messages_Dialogs result = call_rpc(req); std::vector out; std::visit( [&](const auto& v) { @@ -146,7 +146,7 @@ std::vector Client::get_users(const std::vector& users) { if (!p.has_access_hash) throw std::runtime_error("user access hash unknown; resolve the user first"); req.id.push_back(std::make_shared(tl::InputUser{ty::inputUser{p.id, p.access_hash}})); } - const auto raw = session_->call(req); + const auto raw = call_rpc(req); cache_users_chats(raw, {}); std::vector out; for (const auto& u : raw) { @@ -166,7 +166,7 @@ void Client::create_group(const std::string& title, const std::vector& } req.users.push_back(std::make_shared(tl::InputUser{ty::inputUser{p.id, p.access_hash}})); } - session_->call(req); + call_rpc(req); } PeerRef Client::create_channel(const std::string& title, const std::string& about, bool megagroup) { @@ -176,7 +176,7 @@ PeerRef Client::create_channel(const std::string& title, const std::string& abou req.megagroup = megagroup; req.title = title; req.about = about; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); const std::vector>* chats = nullptr; if (const auto* u = std::get_if(&updates.value)) { @@ -208,7 +208,7 @@ void Client::ban_chat_member(const PeerRef& chat, const PeerRef& user, std::int3 req.channel = input_channel_of(chat); req.participant = std::make_shared(input_peer(user)); req.banned_rights = std::make_shared(tl::ChatBannedRights{rights}); - session_->call(req); + call_rpc(req); return; } if (chat.kind != PeerKind::Chat) throw std::invalid_argument("ban_chat_member expects a group or channel"); @@ -219,7 +219,7 @@ void Client::ban_chat_member(const PeerRef& chat, const PeerRef& user, std::int3 req.chat_id = chat.id; req.revoke_history = true; req.user_id = std::make_shared(tl::InputUser{ty::inputUser{user.id, user.access_hash}}); - session_->call(req); + call_rpc(req); } void Client::unban_chat_member(const PeerRef& chat, const PeerRef& user) { @@ -228,7 +228,7 @@ void Client::unban_chat_member(const PeerRef& chat, const PeerRef& user) { req.channel = input_channel_of(chat); req.participant = std::make_shared(input_peer(user)); req.banned_rights = std::make_shared(tl::ChatBannedRights{ty::chatBannedRights{}}); - session_->call(req); + call_rpc(req); } void Client::leave_group(const PeerRef& chat) { @@ -238,7 +238,7 @@ void Client::leave_group(const PeerRef& chat) { req.chat_id = chat.id; req.revoke_history = false; req.user_id = std::make_shared(tl::InputUser{ty::inputUserSelf{}}); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_folder_invites.cpp b/src/client_folder_invites.cpp index 03db204..2c3086c 100644 --- a/src/client_folder_invites.cpp +++ b/src/client_folder_invites.cpp @@ -41,7 +41,7 @@ FolderInviteInfo Client::create_folder_invite_link(std::int32_t filter_id, const req.chatlist = chatlist_of(filter_id); req.title = title; for (const auto& p : peers) req.peers.push_back(std::make_shared(input_peer(p))); - const tl::chatlists_ExportedChatlistInvite res = session_->call(req); + const tl::chatlists_ExportedChatlistInvite res = call_rpc(req); const auto* w = std::get_if(&res.value); if (w == nullptr || !w->invite) throw std::runtime_error("create_folder_invite_link: no invite returned"); return to_folder_info(*w->invite); @@ -51,7 +51,7 @@ std::vector Client::get_folder_invite_links(std::int32_t filte require_session(); fn::chatlists_getExportedInvites req; req.chatlist = chatlist_of(filter_id); - const tl::chatlists_ExportedInvites res = session_->call(req); + const tl::chatlists_ExportedInvites res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -74,7 +74,7 @@ FolderInviteInfo Client::edit_folder_invite_link(std::int32_t filter_id, const s for (const auto& p : *peers) list.push_back(std::make_shared(input_peer(p))); req.peers = std::move(list); } - const tl::ExportedChatlistInvite res = session_->call(req); + const tl::ExportedChatlistInvite res = call_rpc(req); return to_folder_info(res); } @@ -83,14 +83,14 @@ void Client::delete_folder_invite_link(std::int32_t filter_id, const std::string fn::chatlists_deleteExportedInvite req; req.chatlist = chatlist_of(filter_id); req.slug = slug_of(link); - session_->call(req); + call_rpc(req); } FolderInviteCheck Client::check_folder_invite_link(const std::string& link) { require_session(); fn::chatlists_checkChatlistInvite req; req.slug = slug_of(link); - const tl::chatlists_ChatlistInvite res = session_->call(req); + const tl::chatlists_ChatlistInvite res = call_rpc(req); FolderInviteCheck out; if (std::get_if(&res.value)) { out.already_joined = true; @@ -110,14 +110,14 @@ void Client::join_folder_invite_link(const std::string& link, const std::vector< fn::chatlists_joinChatlistInvite req; req.slug = slug_of(link); for (const auto& p : peers) req.peers.push_back(std::make_shared(input_peer(p))); - session_->call(req); + call_rpc(req); } BoostsStatusInfo Client::get_boosts_status(const PeerRef& chat) { require_session(); fn::premium_getBoostsStatus req; req.peer = std::make_shared(input_peer(chat)); - const tl::premium_BoostsStatus res = session_->call(req); + const tl::premium_BoostsStatus res = call_rpc(req); const auto* s = std::get_if(&res.value); if (s == nullptr) throw std::runtime_error("get_boosts_status: unexpected response"); BoostsStatusInfo out; @@ -138,7 +138,7 @@ std::int32_t Client::get_boosters_count(const PeerRef& chat) { req.peer = std::make_shared(input_peer(chat)); req.offset = ""; req.limit = 1; - const tl::premium_BoostsList res = session_->call(req); + const tl::premium_BoostsList res = call_rpc(req); const auto* l = std::get_if(&res.value); if (l == nullptr) return 0; return l->count; @@ -148,7 +148,7 @@ std::int64_t Client::get_stars_balance() { require_session(); fn::payments_getStarsStatus req; req.peer = std::make_shared(tl::InputPeer{ty::inputPeerSelf{}}); - const tl::payments_StarsStatus res = session_->call(req); + const tl::payments_StarsStatus res = call_rpc(req); const auto* s = std::get_if(&res.value); if (s == nullptr || !s->balance) return 0; const auto* amount = std::get_if(&s->balance->value); diff --git a/src/client_folders.cpp b/src/client_folders.cpp index 4ba7fce..b8e4dba 100644 --- a/src/client_folders.cpp +++ b/src/client_folders.cpp @@ -40,7 +40,7 @@ PeerRef peer_for_invite_user(std::int64_t id, std::int64_t access_hash, bool has std::vector Client::get_folders() { require_session(); fn::messages_getDialogFilters req; - const tl::messages_DialogFilters result = session_->call(req); + const tl::messages_DialogFilters result = call_rpc(req); std::vector out; const auto* list = std::get_if(&result.value); if (list == nullptr) return out; @@ -110,7 +110,7 @@ void Client::edit_folder(const FolderInfo& folder) { fn::messages_updateDialogFilter req; req.id = folder.id; req.filter = std::make_shared(tl::DialogFilter{d}); - session_->call(req); + call_rpc(req); } void Client::delete_folder(std::int32_t folder_id) { @@ -118,14 +118,14 @@ void Client::delete_folder(std::int32_t folder_id) { fn::messages_updateDialogFilter req; req.id = folder_id; req.filter = std::nullopt; // no filter means delete - session_->call(req); + call_rpc(req); } void Client::reorder_folders(const std::vector& folder_ids) { require_session(); fn::messages_updateDialogFiltersOrder req; req.order = folder_ids; - session_->call(req); + call_rpc(req); } std::vector Client::get_invite_links(const PeerRef& chat, std::int32_t limit) { @@ -135,7 +135,7 @@ std::vector Client::get_invite_links(const PeerRef& chat, std::int3 req.admin_id = self_user(); req.revoked = false; req.limit = limit; - const tl::messages_ExportedChatInvites result = session_->call(req); + const tl::messages_ExportedChatInvites result = call_rpc(req); std::vector out; if (const auto* list = std::get_if(&result.value)) { for (const auto& inv : list->invites) { @@ -151,7 +151,7 @@ void Client::delete_invite_link(const PeerRef& chat, const std::string& link) { fn::messages_deleteExportedChatInvite req; req.peer = std::make_shared(input_peer(chat)); req.link = link; - session_->call(req); + call_rpc(req); } std::vector Client::get_invite_joiners(const PeerRef& chat, const std::string& link, bool requested, @@ -169,7 +169,7 @@ std::vector Client::get_invite_joiners(const PeerRef& chat, co req.offset_date = offset_date; req.offset_user = offset_user; req.limit = std::min(100, limit - static_cast(out.size())); - const tl::messages_ChatInviteImporters result = session_->call(req); + const tl::messages_ChatInviteImporters result = call_rpc(req); const auto* importers = std::get_if(&result.value); if (importers == nullptr || importers->importers.empty()) break; cache_users_chats(importers->users, {}); @@ -212,7 +212,7 @@ void Client::approve_join_request(const PeerRef& chat, const PeerRef& user, bool if (!u.has_access_hash) throw std::runtime_error("user access hash unknown; resolve the user first"); req.user_id = std::make_shared(tl::InputUser{ty::inputUser{u.id, u.access_hash}}); } - session_->call(req); + call_rpc(req); } void Client::approve_all_join_requests(const PeerRef& chat, bool approved, const std::string& link) { @@ -221,7 +221,7 @@ void Client::approve_all_join_requests(const PeerRef& chat, bool approved, const req.approved = approved; req.peer = std::make_shared(input_peer(chat)); if (!link.empty()) req.link = link; - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_forum.cpp b/src/client_forum.cpp index 4cdcd0d..bfbef0b 100644 --- a/src/client_forum.cpp +++ b/src/client_forum.cpp @@ -47,7 +47,7 @@ ForumTopicInfo Client::create_forum_topic(const PeerRef& channel, const std::str req.title = title; req.icon_color = icon_color; req.random_id = random_id_forum(); - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); // The topic id is the id of the service message that created it. const std::vector>* list = nullptr; @@ -87,7 +87,7 @@ std::vector Client::get_forum_topics(const PeerRef& channel, std req.offset_id = 0; req.offset_topic = 0; req.limit = limit; - const tl::messages_ForumTopics result = session_->call(req); + const tl::messages_ForumTopics result = call_rpc(req); std::vector out; if (const auto* ft = std::get_if(&result.value)) { cache_users_chats(ft->users, ft->chats); @@ -108,7 +108,7 @@ void Client::edit_forum_topic(const PeerRef& channel, std::int32_t topic_id, std req.title = std::move(title); req.closed = closed; req.hidden = hidden; - session_->call(req); + call_rpc(req); } void Client::pin_forum_topic(const PeerRef& channel, std::int32_t topic_id, bool pinned) { @@ -117,7 +117,7 @@ void Client::pin_forum_topic(const PeerRef& channel, std::int32_t topic_id, bool req.peer = channel_peer(channel); req.topic_id = topic_id; req.pinned = pinned; - session_->call(req); + call_rpc(req); } void Client::delete_forum_topic_history(const PeerRef& channel, std::int32_t topic_id) { @@ -125,7 +125,7 @@ void Client::delete_forum_topic_history(const PeerRef& channel, std::int32_t top fn::messages_deleteTopicHistory req; req.peer = channel_peer(channel); req.top_msg_id = topic_id; - session_->call(req); + call_rpc(req); } std::vector Client::get_dialogs(std::int32_t limit, std::int32_t offset_date, std::int32_t offset_id, @@ -138,7 +138,7 @@ std::vector Client::get_dialogs(std::int32_t limit, std::int32_t off offset_peer ? input_peer(*offset_peer) : tl::InputPeer{ty::inputPeerEmpty{}}); req.limit = limit; req.hash = 0; - const tl::messages_Dialogs result = session_->call(req); + const tl::messages_Dialogs result = call_rpc(req); std::vector out; std::visit( diff --git a/src/client_forum_and_stars.cpp b/src/client_forum_and_stars.cpp index 600376b..b83844c 100644 --- a/src/client_forum_and_stars.cpp +++ b/src/client_forum_and_stars.cpp @@ -36,7 +36,7 @@ void Client::set_forum_topic_closed(const PeerRef& channel, std::int32_t topic_i req.peer = std::make_shared(input_peer(channel)); req.topic_id = topic_id; req.closed = closed; - session_->call(req); + call_rpc(req); } void Client::set_general_topic_hidden(const PeerRef& channel, bool hidden) { @@ -45,7 +45,7 @@ void Client::set_general_topic_hidden(const PeerRef& channel, bool hidden) { req.peer = std::make_shared(input_peer(channel)); req.topic_id = 1; req.hidden = hidden; - session_->call(req); + call_rpc(req); } std::vector Client::get_forum_topics_by_id(const PeerRef& channel, @@ -54,7 +54,7 @@ std::vector Client::get_forum_topics_by_id(const PeerRef& channe fn::messages_getForumTopicsByID req; req.peer = std::make_shared(input_peer(channel)); req.topics = topic_ids; - const tl::messages_ForumTopics res = session_->call(req); + const tl::messages_ForumTopics res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -71,7 +71,7 @@ std::vector Client::get_star_gifts() { require_session(); fn::payments_getStarGifts req; req.hash = 0; - const tl::payments_StarGifts res = session_->call(req); + const tl::payments_StarGifts res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -101,7 +101,7 @@ std::vector Client::get_stars_transactions(std::int32_t li req.peer = std::make_shared(tl::InputPeer{ty::inputPeerSelf{}}); req.offset = ""; req.limit = limit; - const tl::payments_StarsStatus res = session_->call(req); + const tl::payments_StarsStatus res = call_rpc(req); std::vector out; const auto* status = std::get_if(&res.value); if (status == nullptr || !status->history) return out; diff --git a/src/client_inline_edit.cpp b/src/client_inline_edit.cpp index d39901d..de694a5 100644 --- a/src/client_inline_edit.cpp +++ b/src/client_inline_edit.cpp @@ -18,7 +18,7 @@ bool Client::edit_inline_message(std::int32_t dc_id, std::int64_t inline_id, std fn::messages_editInlineBotMessage req; req.id = std::make_shared(tl::InputBotInlineMessageID{id}); req.message = text; - return session_->call(req); + return call_rpc(req); } } // namespace cppgram diff --git a/src/client_invite_check.cpp b/src/client_invite_check.cpp index e010378..7705453 100644 --- a/src/client_invite_check.cpp +++ b/src/client_invite_check.cpp @@ -14,7 +14,7 @@ ChatInvitePreview Client::check_chat_invite(const std::string& invite_hash) { require_session(); fn::messages_checkChatInvite req; req.hash = invite_hash; - const tl::ChatInvite result = session_->call(req); + const tl::ChatInvite result = call_rpc(req); ChatInvitePreview out; if (const auto* already = std::get_if(&result.value)) { diff --git a/src/client_invites.cpp b/src/client_invites.cpp index 7dbe020..24908ac 100644 --- a/src/client_invites.cpp +++ b/src/client_invites.cpp @@ -40,7 +40,7 @@ InviteLinkInfo Client::get_invite_link(const PeerRef& chat, const std::string& l fn::messages_getExportedChatInvite req; req.peer = std::make_shared(input_peer(chat)); req.link = link; - const tl::messages_ExportedChatInvite res = session_->call(req); + const tl::messages_ExportedChatInvite res = call_rpc(req); const auto* inv = std::get_if(&res.value); if (inv == nullptr || !inv->invite) { throw std::runtime_error("get_invite_link: no invite returned"); @@ -56,7 +56,7 @@ std::vector Client::get_admin_invite_links(const PeerRef& chat, req.admin_id = input_user_ptr(admin); req.revoked = revoked; req.limit = limit; - const tl::messages_ExportedChatInvites res = session_->call(req); + const tl::messages_ExportedChatInvites res = call_rpc(req); const auto* list = std::get_if(&res.value); std::vector out; if (list == nullptr) return out; @@ -70,7 +70,7 @@ std::vector Client::get_admins_with_invites(const PeerRef& cha require_session(); fn::messages_getAdminsWithInvites req; req.peer = std::make_shared(input_peer(chat)); - const tl::messages_ChatAdminsWithInvites res = session_->call(req); + const tl::messages_ChatAdminsWithInvites res = call_rpc(req); const auto* list = std::get_if(&res.value); std::vector out; if (list == nullptr) return out; @@ -100,7 +100,7 @@ InviteLinkInfo Client::edit_invite_link(const PeerRef& chat, const std::string& req.usage_limit = usage_limit; req.request_needed = request_needed; req.title = title; - const tl::messages_ExportedChatInvite res = session_->call(req); + const tl::messages_ExportedChatInvite res = call_rpc(req); if (const auto* inv = std::get_if(&res.value)) { if (!inv->invite) throw std::runtime_error("edit_invite_link: no invite returned"); return to_invite_info(*inv->invite); @@ -117,7 +117,7 @@ void Client::delete_revoked_invite_links(const PeerRef& chat, const PeerRef& adm fn::messages_deleteRevokedExportedChatInvites req; req.peer = std::make_shared(input_peer(chat)); req.admin_id = input_user_ptr(admin); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_login.cpp b/src/client_login.cpp index 9f8a1cf..f585aba 100644 --- a/src/client_login.cpp +++ b/src/client_login.cpp @@ -18,12 +18,12 @@ void Client::apply_auth_event(AuthEvent event) { if (!next) { throw std::logic_error(std::string("authorization event not allowed in state ") + to_string(auth_state_)); } - auth_state_ = *next; + set_auth_state(*next); } AuthEvent Client::note_rpc_error(std::int32_t code, const std::string& message) noexcept { const AuthEvent event = classify_auth_error(code, message); - if (const auto next = next_auth_state(auth_state_, event)) auth_state_ = *next; + if (const auto next = next_auth_state(auth_state_, event)) set_auth_state(*next); return event; } @@ -33,7 +33,7 @@ bool Client::probe_session() { fn::users_getUsers req; req.id = {std::make_shared(tl::InputUser{ty::inputUserSelf{}})}; try { - const auto users = session_->call(req); + const auto users = call_rpc(req); if (users.empty() || !users.front()) return false; apply_auth_event(AuthEvent::SessionRestored); return true; @@ -59,7 +59,7 @@ LoginCodeInfo Client::send_login_code(const std::string& phone_number) { tl::auth_SentCode res; try { - res = session_->call(req); + res = call_rpc(req); } catch (const mtproto::RpcError& e) { note_rpc_error(e.code(), e.rpc_message()); throw; @@ -106,7 +106,7 @@ bool Client::sign_in_code(const std::string& phone_number, const std::string& co req.phone_code_hash = phone_code_hash; req.phone_code = code; try { - const tl::auth_Authorization res = session_->call(req); + const tl::auth_Authorization res = call_rpc(req); if (std::holds_alternative(res.value)) { apply_auth_event(AuthEvent::PermanentFailure); throw std::runtime_error("this phone number has no Telegram account; sign-up is not supported"); diff --git a/src/client_markup.cpp b/src/client_markup.cpp index 3538548..39fe268 100644 --- a/src/client_markup.cpp +++ b/src/client_markup.cpp @@ -95,7 +95,7 @@ Message Client::send_with_markup(const PeerRef& target, const std::string& text, } else if (reply_keyboard && !reply_keyboard->rows.empty()) { req.reply_markup = to_tl_reply_markup(*reply_keyboard); } - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, text, 0)) return *msg; throw std::runtime_error("message was sent but no id was returned by the server"); } @@ -106,7 +106,7 @@ void Client::edit_message_reply_markup(const PeerRef& target, std::int32_t messa req.peer = std::make_shared(input_peer(target)); req.id = message_id; req.reply_markup = to_tl_inline_markup(keyboard); - session_->call(req); + call_rpc(req); } Message Client::send_poll(const PeerRef& target, const std::string& question, const std::vector& options, @@ -149,7 +149,7 @@ std::vector Client::copy_messages(const PeerRef& from, const PeerRef& t req.drop_author = true; req.drop_media_captions = drop_captions; for (std::size_t i = 0; i < message_ids.size(); ++i) req.random_id.push_back(random_id_markup()); - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); std::vector out; const std::vector>* list = nullptr; if (const auto* u = std::get_if(&updates.value)) { diff --git a/src/client_media.cpp b/src/client_media.cpp index f014322..d6cfb22 100644 --- a/src/client_media.cpp +++ b/src/client_media.cpp @@ -53,7 +53,7 @@ ChatMember Client::get_chat_member(const PeerRef& chat, const PeerRef& user) { fn::channels_getParticipant req; req.channel = channel_ref(chat); req.participant = std::make_shared(input_peer(user)); - const tl::channels_ChannelParticipant result = session_->call(req); + const tl::channels_ChannelParticipant result = call_rpc(req); const auto* part = std::get_if(&result.value); if (part == nullptr) throw std::runtime_error("channels.getParticipant returned an unexpected type"); cache_users_chats(part->users, part->chats); @@ -68,7 +68,7 @@ std::vector Client::get_chat_members(const PeerRef& chat, MemberFilt // Basic groups keep their members in messages.getFullChat, not in channels.getParticipants. fn::messages_getFullChat full_req; full_req.chat_id = chat.id; - const tl::messages_ChatFull full = session_->call(full_req); + const tl::messages_ChatFull full = call_rpc(full_req); std::vector group_out; const auto* mcf = std::get_if(&full.value); if (mcf == nullptr || !mcf->full_chat) return group_out; @@ -97,7 +97,7 @@ std::vector Client::get_chat_members(const PeerRef& chat, MemberFilt req.offset = 0; req.limit = limit; req.hash = 0; - const tl::channels_ChannelParticipants result = session_->call(req); + const tl::channels_ChannelParticipants result = call_rpc(req); std::vector out; if (const auto* cp = std::get_if(&result.value)) { cache_users_chats(cp->users, cp->chats); @@ -213,7 +213,7 @@ Message Client::edit_message_caption(const PeerRef& target, std::int32_t message req.peer = std::make_shared(input_peer(target)); req.id = message_id; req.message = caption; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, caption, message_id)) return *msg; throw std::runtime_error("caption edited but the server returned no message"); } @@ -233,7 +233,7 @@ Message Client::edit_message_media(const PeerRef& target, std::int32_t message_i req.id = message_id; req.media = std::make_shared(tl::InputMedia{doc}); if (!caption.empty()) req.message = caption; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, caption, message_id)) return *msg; throw std::runtime_error("media edited but the server returned no message"); } diff --git a/src/client_message.cpp b/src/client_message.cpp index bee76d3..c8da0ab 100644 --- a/src/client_message.cpp +++ b/src/client_message.cpp @@ -51,7 +51,7 @@ void Client::vote_poll(const Message& poll_message, const std::vectoroptions[idx].option_id; req.options.emplace_back(id.begin(), id.end()); } - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_more.cpp b/src/client_more.cpp index 8e2b495..176e925 100644 --- a/src/client_more.cpp +++ b/src/client_more.cpp @@ -50,7 +50,7 @@ std::vector Client::get_scheduled_messages(const PeerRef& chat) { fn::messages_getScheduledHistory req; req.peer = std::make_shared(input_peer(chat)); req.hash = 0; - return collect_messages(session_->call(req)); + return collect_messages(call_rpc(req)); } std::vector Client::get_discussion_replies(const PeerRef& chat, std::int32_t message_id, std::int32_t limit) { @@ -59,7 +59,7 @@ std::vector Client::get_discussion_replies(const PeerRef& chat, std::in req.peer = std::make_shared(input_peer(chat)); req.msg_id = message_id; req.limit = limit; - return collect_messages(session_->call(req)); + return collect_messages(call_rpc(req)); } std::vector Client::search_global(const std::string& query, std::int32_t limit) { @@ -69,7 +69,7 @@ std::vector Client::search_global(const std::string& query, std::int32_ req.filter = std::make_shared(tl::MessagesFilter{ty::inputMessagesFilterEmpty{}}); req.offset_peer = std::make_shared(tl::InputPeer{ty::inputPeerEmpty{}}); req.limit = limit; - return collect_messages(session_->call(req)); + return collect_messages(call_rpc(req)); } void Client::delete_chat_history(const PeerRef& chat, bool revoke) { @@ -82,7 +82,7 @@ void Client::delete_chat_history(const PeerRef& chat, bool revoke) { req.max_id = 0; req.revoke = revoke; req.just_clear = false; - session_->call(req); + call_rpc(req); } void Client::answer_pre_checkout_query(std::int64_t query_id, bool success, const std::string& error) { @@ -91,7 +91,7 @@ void Client::answer_pre_checkout_query(std::int64_t query_id, bool success, cons req.query_id = query_id; req.success = success; if (!success && !error.empty()) req.error_ = error; - session_->call(req); + call_rpc(req); } std::string Client::request_callback_answer(const PeerRef& chat, std::int32_t message_id, const std::string& data) { @@ -100,7 +100,7 @@ std::string Client::request_callback_answer(const PeerRef& chat, std::int32_t me req.peer = std::make_shared(input_peer(chat)); req.msg_id = message_id; req.data = Bytes(data.begin(), data.end()); - const tl::messages_BotCallbackAnswer answer = session_->call(req); + const tl::messages_BotCallbackAnswer answer = call_rpc(req); if (const auto* a = std::get_if(&answer.value)) { if (a->url) return *a->url; if (a->message) return *a->message; @@ -114,7 +114,7 @@ std::string Client::export_chat_invite_link(const PeerRef& chat) { require_session(); fn::messages_exportChatInvite req; req.peer = std::make_shared(input_peer(chat)); - const tl::ExportedChatInvite invite = session_->call(req); + const tl::ExportedChatInvite invite = call_rpc(req); if (const auto* e = std::get_if(&invite.value)) return e->link; throw std::runtime_error("export_chat_invite_link: join-request invite returned, use create_chat_invite_link"); } @@ -129,7 +129,7 @@ std::string Client::create_chat_invite_link(const PeerRef& chat, std::optionalcall(req); + const tl::ExportedChatInvite invite = call_rpc(req); if (const auto* e = std::get_if(&invite.value)) return e->link; if (const auto* pj = std::get_if(&invite.value)) { (void)pj; @@ -144,7 +144,7 @@ void Client::revoke_chat_invite_link(const PeerRef& chat, const std::string& lin req.peer = std::make_shared(input_peer(chat)); req.link = link; req.revoked = true; - session_->call(req); + call_rpc(req); } void Client::set_chat_title(const PeerRef& chat, const std::string& title) { @@ -153,14 +153,14 @@ void Client::set_chat_title(const PeerRef& chat, const std::string& title) { fn::channels_editTitle req; req.channel = channel_input(chat); req.title = title; - session_->call(req); + call_rpc(req); return; } if (chat.kind != PeerKind::Chat) throw std::invalid_argument("set_chat_title expects a group or channel"); fn::messages_editChatTitle req; req.chat_id = chat.id; req.title = title; - session_->call(req); + call_rpc(req); } void Client::promote_chat_member(const PeerRef& chat, const PeerRef& user, const AdminRights& rights, @@ -181,7 +181,7 @@ void Client::promote_chat_member(const PeerRef& chat, const PeerRef& user, const req.user_id = input_user_ptr(user); req.admin_rights = std::make_shared(tl::ChatAdminRights{r}); if (!rank.empty()) req.rank = rank; - session_->call(req); + call_rpc(req); } void Client::restrict_chat_member(const PeerRef& chat, const PeerRef& user, const Restrictions& restrictions, @@ -201,7 +201,7 @@ void Client::restrict_chat_member(const PeerRef& chat, const PeerRef& user, cons req.channel = channel_input(chat); req.participant = std::make_shared(input_peer(user)); req.banned_rights = std::make_shared(tl::ChatBannedRights{rights}); - session_->call(req); + call_rpc(req); } // ---- contacts, account @@ -214,21 +214,21 @@ void Client::add_contact(const PeerRef& user, const std::string& first_name, con req.first_name = first_name; req.last_name = last_name; req.phone = phone; - session_->call(req); + call_rpc(req); } void Client::delete_contacts(const std::vector& users) { require_session(); fn::contacts_deleteContacts req; for (const auto& u : users) req.id.push_back(input_user_ptr(u)); - session_->call(req); + call_rpc(req); } std::vector Client::get_contacts() { require_session(); fn::contacts_getContacts req; req.hash = 0; - const tl::contacts_Contacts result = session_->call(req); + const tl::contacts_Contacts result = call_rpc(req); std::vector out; if (const auto* c = std::get_if(&result.value)) { cache_users_chats(c->users, {}); @@ -252,7 +252,7 @@ std::vector Client::import_contacts(const std::vector& conta pc.last_name = c.last_name; req.contacts.push_back(std::make_shared(tl::InputContact{pc})); } - const tl::contacts_ImportedContacts result = session_->call(req); + const tl::contacts_ImportedContacts result = call_rpc(req); std::vector out; std::visit( [&](const auto& v) { @@ -272,14 +272,14 @@ void Client::block_user(const PeerRef& user) { require_session(); fn::contacts_block req; req.id = std::make_shared(input_peer(user)); - session_->call(req); + call_rpc(req); } void Client::unblock_user(const PeerRef& user) { require_session(); fn::contacts_unblock req; req.id = std::make_shared(input_peer(user)); - session_->call(req); + call_rpc(req); } User Client::update_profile(std::optional first_name, std::optional last_name, @@ -289,7 +289,7 @@ User Client::update_profile(std::optional first_name, std::optional req.first_name = std::move(first_name); req.last_name = std::move(last_name); req.about = std::move(about); - const tl::User raw = session_->call(req); + const tl::User raw = call_rpc(req); if (auto user = detail::user_from_tl(raw)) return *user; throw std::runtime_error("account.updateProfile returned an unusable user"); } @@ -298,7 +298,7 @@ User Client::set_username(const std::string& username) { require_session(); fn::account_updateUsername req; req.username = username; - const tl::User raw = session_->call(req); + const tl::User raw = call_rpc(req); if (auto user = detail::user_from_tl(raw)) return *user; throw std::runtime_error("account.updateUsername returned an unusable user"); } @@ -306,8 +306,8 @@ User Client::set_username(const std::string& username) { void Client::log_out() { require_session(); fn::auth_logOut req; - session_->call(req); - if (next_auth_state(auth_state_, AuthEvent::LoggedOut)) auth_state_ = *next_auth_state(auth_state_, AuthEvent::LoggedOut); + call_rpc(req); + if (const auto next = next_auth_state(auth_state_, AuthEvent::LoggedOut)) set_auth_state(*next); disconnect(); } @@ -324,7 +324,7 @@ void Client::set_bot_commands(const std::vector& commands, const bc.description = c.description; req.commands.push_back(std::make_shared(tl::BotCommand{bc})); } - session_->call(req); + call_rpc(req); } std::vector Client::get_bot_commands(const std::string& lang_code) { @@ -332,7 +332,7 @@ std::vector Client::get_bot_commands(const std::string& lang_cod fn::bots_getBotCommands req; req.scope = std::make_shared(tl::BotCommandScope{ty::botCommandScopeDefault{}}); req.lang_code = lang_code; - const auto list = session_->call(req); + const auto list = call_rpc(req); std::vector out; for (const auto& c : list) { if (!c) continue; @@ -361,7 +361,7 @@ void Client::answer_inline_query(std::int64_t query_id, const std::vector(tl::InputBotInlineMessage{text}); req.results.push_back(std::make_shared(tl::InputBotInlineResult{r})); } - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_own_star_gifts.cpp b/src/client_own_star_gifts.cpp index d41a9bd..6d5bd6c 100644 --- a/src/client_own_star_gifts.cpp +++ b/src/client_own_star_gifts.cpp @@ -19,14 +19,14 @@ bool Client::save_star_gift(std::int32_t msg_id, bool save) { fn::payments_saveStarGift req; req.unsave = !save; req.stargift = std::make_shared(own_gift(msg_id)); - return session_->call(req); + return call_rpc(req); } bool Client::convert_star_gift(std::int32_t msg_id) { require_session(); fn::payments_convertStarGift req; req.stargift = std::make_shared(own_gift(msg_id)); - return session_->call(req); + return call_rpc(req); } void Client::upgrade_star_gift(std::int32_t msg_id, bool keep_original_details) { @@ -34,7 +34,7 @@ void Client::upgrade_star_gift(std::int32_t msg_id, bool keep_original_details) fn::payments_upgradeStarGift req; req.keep_original_details = keep_original_details; req.stargift = std::make_shared(own_gift(msg_id)); - session_->call(req); + call_rpc(req); } void Client::transfer_star_gift(std::int32_t msg_id, const PeerRef& to) { @@ -42,7 +42,7 @@ void Client::transfer_star_gift(std::int32_t msg_id, const PeerRef& to) { fn::payments_transferStarGift req; req.stargift = std::make_shared(own_gift(msg_id)); req.to_id = std::make_shared(input_peer(to)); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_payment_receipts.cpp b/src/client_payment_receipts.cpp index dc44b4a..2eb9d76 100644 --- a/src/client_payment_receipts.cpp +++ b/src/client_payment_receipts.cpp @@ -13,7 +13,7 @@ PaymentReceiptInfo Client::get_payment_receipt(const PeerRef& chat, std::int32_t fn::payments_getPaymentReceipt req; req.peer = std::make_shared(input_peer(chat)); req.msg_id = msg_id; - const tl::payments_PaymentReceipt result = session_->call(req); + const tl::payments_PaymentReceipt result = call_rpc(req); const auto* receipt = std::get_if(&result.value); if (receipt == nullptr) throw std::runtime_error("get_payment_receipt: unexpected result"); diff --git a/src/client_poll_stop.cpp b/src/client_poll_stop.cpp index a9f8859..6a809ad 100644 --- a/src/client_poll_stop.cpp +++ b/src/client_poll_stop.cpp @@ -44,7 +44,7 @@ void Client::stop_poll(const Message& poll_message) { req.id = poll_message.id; req.media = std::make_shared( tl::InputMedia{ty::inputMediaPoll{std::make_shared(tl::Poll{std::move(p)})}}); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_profile.cpp b/src/client_profile.cpp index 7b4b5ca..ce408ab 100644 --- a/src/client_profile.cpp +++ b/src/client_profile.cpp @@ -43,7 +43,7 @@ std::vector Client::get_profile_photos(const PeerRef& user, int li req.offset = 0; req.max_id = max_id; req.limit = std::min(100, limit - static_cast(out.size())); - const tl::photos_Photos result = session_->call(req); + const tl::photos_Photos result = call_rpc(req); const std::vector>* photos = nullptr; if (const auto* a = std::get_if(&result.value)) { @@ -74,7 +74,7 @@ ProfilePhoto Client::set_profile_photo(const std::string& path) { std::int64_t size = 0; fn::photos_uploadProfilePhoto req; req.file = upload(path, name, size); - const tl::photos_Photo result = session_->call(req); + const tl::photos_Photo result = call_rpc(req); const auto* wrap = std::get_if(&result.value); if (wrap == nullptr || !wrap->photo) { throw std::runtime_error("photos.uploadProfilePhoto returned no photo"); @@ -86,7 +86,7 @@ ProfilePhoto Client::set_profile_photo_from(const ProfilePhoto& photo) { require_session(); fn::photos_updateProfilePhoto req; req.id = std::make_shared(input_photo_of(photo)); - const tl::photos_Photo result = session_->call(req); + const tl::photos_Photo result = call_rpc(req); const auto* wrap = std::get_if(&result.value); if (wrap == nullptr || !wrap->photo) { throw std::runtime_error("photos.updateProfilePhoto returned no photo"); @@ -101,7 +101,7 @@ std::size_t Client::delete_profile_photos(const std::vector& photo for (const auto& p : photos) { req.id.push_back(std::make_shared(input_photo_of(p))); } - const std::vector removed = session_->call(req); + const std::vector removed = call_rpc(req); return removed.size(); } @@ -115,7 +115,7 @@ std::vector Client::get_common_chats(const PeerRef& user, int limit) { req.user_id = input_user_ptr(user); req.max_id = max_id; req.limit = std::min(100, limit - static_cast(out.size())); - const tl::messages_Chats result = session_->call(req); + const tl::messages_Chats result = call_rpc(req); const std::vector>* chats = nullptr; if (const auto* a = std::get_if(&result.value)) { @@ -143,7 +143,7 @@ std::vector Client::get_common_chats(const PeerRef& user, int limit) { std::vector Client::get_sessions() { require_session(); fn::account_getAuthorizations req; - const tl::account_Authorizations result = session_->call(req); + const tl::account_Authorizations result = call_rpc(req); std::vector out; const auto* list = std::get_if(&result.value); if (list == nullptr) return out; @@ -177,7 +177,7 @@ bool Client::terminate_session(std::int64_t hash) { require_session(); fn::account_resetAuthorization req; req.hash = hash; - return session_->call(req); + return call_rpc(req); } } // namespace cppgram diff --git a/src/client_send.cpp b/src/client_send.cpp index af9cb1e..dbf43fc 100644 --- a/src/client_send.cpp +++ b/src/client_send.cpp @@ -58,7 +58,7 @@ Message Client::send_quote(const PeerRef& target, const std::string& text, std:: reply.reply_to_msg_id = reply_to_id; reply.quote_text = quote_text; req.reply_to = std::make_shared(tl::InputReplyTo{reply}); - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, text, 0)) return *msg; throw std::runtime_error("quoted message was sent but no message was returned by the server"); } @@ -71,7 +71,7 @@ void Client::send_scheduled(const PeerRef& target, const std::string& text, std: req.message = text; req.random_id = random_i64(); req.schedule_date = static_cast(unix_time); - session_->call(req); + call_rpc(req); } std::vector Client::send_album(const PeerRef& target, const std::vector& paths, @@ -101,7 +101,7 @@ std::vector Client::send_album(const PeerRef& target, const std::vector doc.attributes.push_back(std::make_shared(tl::DocumentAttribute{ty::documentAttributeFilename{name}})); up.media = std::make_shared(tl::InputMedia{doc}); } - const tl::MessageMedia uploaded = session_->call(up); + const tl::MessageMedia uploaded = call_rpc(up); std::shared_ptr ref; if (const auto* mp = std::get_if(&uploaded.value)) { @@ -127,7 +127,7 @@ std::vector Client::send_album(const PeerRef& target, const std::vector req.multi_media.push_back(std::make_shared(tl::InputSingleMedia{single})); } - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); std::vector out; if (const auto* u = std::get_if(&updates.value)) { for (const auto& up : u->updates) { @@ -151,7 +151,7 @@ std::string Client::translate_text(const std::string& text, const std::string& t req.text = std::vector>{ std::make_shared(tl::TextWithEntities{src})}; req.to_lang = to_lang; - const tl::messages_TranslatedText result = session_->call(req); + const tl::messages_TranslatedText result = call_rpc(req); const auto* list = std::get_if(&result.value); if (list == nullptr || list->result.empty() || !list->result.front()) { throw std::runtime_error("messages.translateText returned no text"); @@ -165,7 +165,7 @@ void Client::set_online(bool online) { require_session(); fn::account_updateStatus req; req.offline = !online; - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_session.cpp b/src/client_session.cpp index 2f91622..31a38d6 100644 --- a/src/client_session.cpp +++ b/src/client_session.cpp @@ -46,14 +46,14 @@ bool Client::set_bot_info(const std::string& lang_code, const std::string& name, if (!name.empty()) req.name = name; if (!about.empty()) req.about = about; if (!description.empty()) req.description = description; - return session_->call(req); + return call_rpc(req); } BotInfoText Client::get_bot_info(const std::string& lang_code) { require_session(); fn::bots_getBotInfo req; req.lang_code = lang_code; - const tl::bots_BotInfo result = session_->call(req); + const tl::bots_BotInfo result = call_rpc(req); BotInfoText out; if (const auto* info = std::get_if(&result.value)) { out.name = info->name; @@ -68,7 +68,7 @@ bool Client::set_default_menu_button() { fn::bots_setBotMenuButton req; req.user_id = self_input_user(); req.button = std::make_shared(tl::BotMenuButton{ty::botMenuButtonDefault{}}); - return session_->call(req); + return call_rpc(req); } } // namespace cppgram @@ -112,14 +112,14 @@ void Client::set_privacy(PrivacyKey key, PrivacyLevel level) { break; } req.rules.push_back(rule); - session_->call(req); + call_rpc(req); } PrivacyLevel Client::get_privacy(PrivacyKey key) { require_session(); fn::account_getPrivacy req; req.key = privacy_key(key); - const tl::account_PrivacyRules result = session_->call(req); + const tl::account_PrivacyRules result = call_rpc(req); const auto* rules = std::get_if(&result.value); if (rules == nullptr) return PrivacyLevel::Nobody; bool contacts = false; @@ -188,7 +188,7 @@ bool Client::check_password(const std::string& password) { if (pw == nullptr) throw std::runtime_error("unexpected account.getPassword response"); fn::auth_checkPassword req; req.password = std::make_shared(make_password_check(*pw, password)); - session_->call(req); + call_rpc(req); return true; } @@ -227,7 +227,7 @@ bool Client::set_password(const std::string& current_password, const std::string req.password = std::make_shared(make_password_check(*pw, current_password)); } req.new_settings = std::make_shared(tl::account_PasswordInputSettings{settings}); - return session_->call(req); + return call_rpc(req); } bool Client::remove_password(const std::string& current_password) { @@ -244,7 +244,7 @@ bool Client::remove_password(const std::string& current_password) { fn::account_updatePasswordSettings req; req.password = std::make_shared(make_password_check(*pw, current_password)); req.new_settings = std::make_shared(tl::account_PasswordInputSettings{settings}); - return session_->call(req); + return call_rpc(req); } } // namespace cppgram @@ -271,7 +271,7 @@ bool Client::set_favorite_sticker(const DocumentRef& sticker, bool favorite) { req.id = std::make_shared( tl::InputDocument{ty::inputDocument{sticker.id, sticker.access_hash, sticker.file_reference}}); req.unfave = !favorite; - return session_->call(req); + return call_rpc(req); } bool Client::set_favorite_sticker(const Message& sticker, bool favorite) { @@ -289,7 +289,7 @@ std::vector Client::get_favorite_stickers() { require_session(); fn::messages_getFavedStickers req; req.hash = 0; - const tl::messages_FavedStickers result = session_->call(req); + const tl::messages_FavedStickers result = call_rpc(req); std::vector out; if (const auto* list = std::get_if(&result.value)) { for (const auto& d : list->stickers) { @@ -312,7 +312,7 @@ bool Client::set_menu_button_url(const std::string& text, const std::string& url fn::bots_setBotMenuButton req; req.user_id = self_input_user(); req.button = std::make_shared(tl::BotMenuButton{ty::botMenuButton{text, url}}); - return session_->call(req); + return call_rpc(req); } } // namespace cppgram diff --git a/src/client_star_gift_collections.cpp b/src/client_star_gift_collections.cpp index de2f889..2574e76 100644 --- a/src/client_star_gift_collections.cpp +++ b/src/client_star_gift_collections.cpp @@ -40,7 +40,7 @@ StarGiftCollectionInfo Client::create_star_gift_collection(const PeerRef& peer, req.peer = std::make_shared(input_peer(peer)); req.title = title; req.stargift = own_gifts(msg_ids); - const tl::StarGiftCollection result = session_->call(req); + const tl::StarGiftCollection result = call_rpc(req); return to_collection(result); } @@ -55,7 +55,7 @@ StarGiftCollectionInfo Client::update_star_gift_collection(const PeerRef& peer, req.title = title; if (!add_msg_ids.empty()) req.add_stargift = own_gifts(add_msg_ids); if (!remove_msg_ids.empty()) req.delete_stargift = own_gifts(remove_msg_ids); - const tl::StarGiftCollection result = session_->call(req); + const tl::StarGiftCollection result = call_rpc(req); return to_collection(result); } diff --git a/src/client_star_gift_listing.cpp b/src/client_star_gift_listing.cpp index 31a7eb9..df9d095 100644 --- a/src/client_star_gift_listing.cpp +++ b/src/client_star_gift_listing.cpp @@ -48,7 +48,7 @@ SavedStarGiftPage Client::get_saved_star_gifts(const PeerRef& peer, const std::s req.offset = offset; req.limit = limit; req.collection_id = collection_id; - const tl::payments_SavedStarGifts result = session_->call(req); + const tl::payments_SavedStarGifts result = call_rpc(req); const auto* page = std::get_if(&result.value); if (page == nullptr) throw std::runtime_error("get_saved_star_gifts: unexpected result"); @@ -67,7 +67,7 @@ std::vector Client::get_star_gift_collections(const Peer fn::payments_getStarGiftCollections req; req.peer = std::make_shared(input_peer(peer)); req.hash = 0; - const tl::payments_StarGiftCollections result = session_->call(req); + const tl::payments_StarGiftCollections result = call_rpc(req); const auto* list = std::get_if(&result.value); if (list == nullptr) throw std::runtime_error("get_star_gift_collections: unexpected result"); @@ -89,7 +89,7 @@ UniqueStarGiftInfo Client::get_unique_star_gift(const std::string& slug) { require_session(); fn::payments_getUniqueStarGift req; req.slug = slug; - const tl::payments_UniqueStarGift result = session_->call(req); + const tl::payments_UniqueStarGift result = call_rpc(req); const auto* wrapped = std::get_if(&result.value); if (wrapped == nullptr || !wrapped->gift) throw std::runtime_error("get_unique_star_gift: unexpected result"); const auto* unique = std::get_if(&wrapped->gift->value); @@ -108,7 +108,7 @@ std::vector Client::get_star_gift_upgrade_prices(std::int64_t gift require_session(); fn::payments_getStarGiftUpgradePreview req; req.gift_id = gift_id; - const tl::payments_StarGiftUpgradePreview result = session_->call(req); + const tl::payments_StarGiftUpgradePreview result = call_rpc(req); const auto* preview = std::get_if(&result.value); if (preview == nullptr) throw std::runtime_error("get_star_gift_upgrade_prices: unexpected result"); diff --git a/src/client_stickers.cpp b/src/client_stickers.cpp index 94a150f..2e699ba 100644 --- a/src/client_stickers.cpp +++ b/src/client_stickers.cpp @@ -74,7 +74,7 @@ Chat Client::get_chat(const PeerRef& peer) { if (peer.kind == PeerKind::Channel) { fn::channels_getFullChannel req; req.channel = std::make_shared(tl::InputChannel{ty::inputChannel{peer.id, peer.access_hash}}); - const tl::messages_ChatFull full = session_->call(req); + const tl::messages_ChatFull full = call_rpc(req); const auto* mcf = std::get_if(&full.value); if (mcf == nullptr || !mcf->full_chat) throw std::runtime_error("channels.getFullChannel returned no data"); cache_users_chats(mcf->users, mcf->chats); @@ -93,7 +93,7 @@ Chat Client::get_chat(const PeerRef& peer) { if (peer.kind == PeerKind::Chat) { fn::messages_getFullChat req; req.chat_id = peer.id; - const tl::messages_ChatFull full = session_->call(req); + const tl::messages_ChatFull full = call_rpc(req); const auto* mcf = std::get_if(&full.value); if (mcf == nullptr || !mcf->full_chat) throw std::runtime_error("messages.getFullChat returned no data"); cache_users_chats(mcf->users, mcf->chats); @@ -116,7 +116,7 @@ Chat Client::get_chat(const PeerRef& peer) { if (peer.kind == PeerKind::User) { fn::users_getFullUser req; req.id = input_user_ptr(peer); - const tl::users_UserFull full = session_->call(req); + const tl::users_UserFull full = call_rpc(req); const auto* uf = std::get_if(&full.value); if (uf == nullptr) throw std::runtime_error("users.getFullUser returned no data"); cache_users_chats(uf->users, uf->chats); @@ -163,7 +163,7 @@ StickerSetInfo Client::create_sticker_set(const std::string& title, const std::s fn::messages_uploadMedia up; up.peer = std::make_shared(tl::InputPeer{ty::inputPeerSelf{}}); up.media = std::make_shared(tl::InputMedia{doc}); - const tl::MessageMedia uploaded = session_->call(up); + const tl::MessageMedia uploaded = call_rpc(up); const auto* md = std::get_if(&uploaded.value); if (md == nullptr || !md->document || !*md->document) { throw std::runtime_error("messages.uploadMedia did not return a document for " + sf.path); @@ -176,7 +176,7 @@ StickerSetInfo Client::create_sticker_set(const std::string& title, const std::s item.emoji = sf.emoji; req.stickers.push_back(std::make_shared(tl::InputStickerSetItem{item})); } - const tl::messages_StickerSet result = session_->call(req); + const tl::messages_StickerSet result = call_rpc(req); const auto* set = std::get_if(&result.value); if (set == nullptr || !set->set) throw std::runtime_error("stickers.createStickerSet returned no set"); const auto* meta = std::get_if(&set->set->value); @@ -189,7 +189,7 @@ StickerSetContent Client::get_sticker_set(const std::string& short_name) { fn::messages_getStickerSet req; req.stickerset = short_name_set(short_name); req.hash = 0; - const tl::messages_StickerSet result = session_->call(req); + const tl::messages_StickerSet result = call_rpc(req); const auto* set = std::get_if(&result.value); if (set == nullptr || !set->set) throw std::runtime_error("sticker set not found: " + short_name); const auto* meta = std::get_if(&set->set->value); @@ -207,7 +207,7 @@ std::vector Client::get_installed_sticker_sets() { require_session(); fn::messages_getAllStickers req; req.hash = 0; - const tl::messages_AllStickers result = session_->call(req); + const tl::messages_AllStickers result = call_rpc(req); std::vector out; if (const auto* all = std::get_if(&result.value)) { for (const auto& s : all->sets) { @@ -223,7 +223,7 @@ std::vector Client::get_stickers_by_emoji(const std::string& emoji) { fn::messages_getStickers req; req.emoticon = emoji; req.hash = 0; - const tl::messages_Stickers result = session_->call(req); + const tl::messages_Stickers result = call_rpc(req); std::vector out; if (const auto* st = std::get_if(&result.value)) { for (const auto& d : st->stickers) { @@ -238,7 +238,7 @@ void Client::delete_sticker_set(const std::string& short_name) { require_session(); fn::stickers_deleteStickerSet req; req.stickerset = short_name_set(short_name); - session_->call(req); + call_rpc(req); } void Client::rename_sticker_set(const std::string& short_name, const std::string& title) { @@ -246,14 +246,14 @@ void Client::rename_sticker_set(const std::string& short_name, const std::string fn::stickers_renameStickerSet req; req.stickerset = short_name_set(short_name); req.title = title; - session_->call(req); + call_rpc(req); } void Client::remove_sticker_from_set(const MediaInfo& sticker) { require_session(); fn::stickers_removeStickerFromSet req; req.sticker = input_document_of(sticker); - session_->call(req); + call_rpc(req); } void Client::add_existing_sticker_to_set(const std::string& short_name, const MediaInfo& sticker, @@ -265,7 +265,7 @@ void Client::add_existing_sticker_to_set(const std::string& short_name, const Me item.document = input_document_of(sticker); item.emoji = emoji; req.sticker = std::make_shared(tl::InputStickerSetItem{item}); - session_->call(req); + call_rpc(req); } void Client::change_sticker_position(const MediaInfo& sticker, std::int32_t position) { @@ -273,7 +273,7 @@ void Client::change_sticker_position(const MediaInfo& sticker, std::int32_t posi fn::stickers_changeStickerPosition req; req.sticker = input_document_of(sticker); req.position = position; - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_stories_messages.cpp b/src/client_stories_messages.cpp index 65f333d..60d1092 100644 --- a/src/client_stories_messages.cpp +++ b/src/client_stories_messages.cpp @@ -50,7 +50,7 @@ std::vector Client::get_message_read_participants(const fn::messages_getMessageReadParticipants req; req.peer = std::make_shared(input_peer(chat)); req.msg_id = message_id; - const auto res = session_->call(req); + const auto res = call_rpc(req); std::vector out; for (const auto& p : res) { if (!p) continue; @@ -66,14 +66,14 @@ void Client::delete_scheduled_messages(const PeerRef& chat, const std::vector(input_peer(chat)); req.id = message_ids; - session_->call(req); + call_rpc(req); } std::vector Client::get_available_effects() { require_session(); fn::messages_getAvailableEffects req; req.hash = 0; - const tl::messages_AvailableEffects res = session_->call(req); + const tl::messages_AvailableEffects res = call_rpc(req); std::vector out; const auto* list = std::get_if(&res.value); if (list == nullptr) return out; @@ -101,7 +101,7 @@ void Client::send_reaction_with_options(const PeerRef& chat, std::int32_t messag std::vector> reactions; reactions.push_back(std::make_shared(tl::Reaction{ty::reactionEmoji{emoji}})); req.reaction = std::move(reactions); - session_->call(req); + call_rpc(req); } void Client::clear_reaction(const PeerRef& chat, std::int32_t message_id) { @@ -110,7 +110,7 @@ void Client::clear_reaction(const PeerRef& chat, std::int32_t message_id) { req.peer = std::make_shared(input_peer(chat)); req.msg_id = message_id; req.reaction = std::vector>{}; - session_->call(req); + call_rpc(req); } // ---- phase 4: stories @@ -121,7 +121,7 @@ std::vector Client::get_pinned_stories(const PeerRef& chat, std::int3 req.peer = std::make_shared(input_peer(chat)); req.offset_id = offset_id; req.limit = limit; - return to_story_list(session_->call(req)); + return to_story_list(call_rpc(req)); } std::vector Client::get_stories_archive(const PeerRef& chat, std::int32_t offset_id, std::int32_t limit) { @@ -130,7 +130,7 @@ std::vector Client::get_stories_archive(const PeerRef& chat, std::int req.peer = std::make_shared(input_peer(chat)); req.offset_id = offset_id; req.limit = limit; - return to_story_list(session_->call(req)); + return to_story_list(call_rpc(req)); } std::vector Client::get_stories_by_id(const PeerRef& chat, const std::vector& story_ids) { @@ -138,7 +138,7 @@ std::vector Client::get_stories_by_id(const PeerRef& chat, const std: fn::stories_getStoriesByID req; req.peer = std::make_shared(input_peer(chat)); req.id = story_ids; - return to_story_list(session_->call(req)); + return to_story_list(call_rpc(req)); } std::vector Client::delete_stories(const PeerRef& chat, const std::vector& story_ids) { @@ -146,7 +146,7 @@ std::vector Client::delete_stories(const PeerRef& chat, const std: fn::stories_deleteStories req; req.peer = std::make_shared(input_peer(chat)); req.id = story_ids; - return session_->call(req); + return call_rpc(req); } std::vector Client::toggle_pinned_stories(const PeerRef& chat, const std::vector& story_ids, @@ -156,7 +156,7 @@ std::vector Client::toggle_pinned_stories(const PeerRef& chat, con req.peer = std::make_shared(input_peer(chat)); req.id = story_ids; req.pinned = pinned; - return session_->call(req); + return call_rpc(req); } std::string Client::export_story_link(const PeerRef& chat, std::int32_t story_id) { @@ -164,7 +164,7 @@ std::string Client::export_story_link(const PeerRef& chat, std::int32_t story_id fn::stories_exportStoryLink req; req.peer = std::make_shared(input_peer(chat)); req.id = story_id; - const tl::ExportedStoryLink res = session_->call(req); + const tl::ExportedStoryLink res = call_rpc(req); const auto* link = std::get_if(&res.value); if (link == nullptr) throw std::runtime_error("export_story_link: no link returned"); return link->link; diff --git a/src/client_story_posting.cpp b/src/client_story_posting.cpp index bd5c887..c79bf0a 100644 --- a/src/client_story_posting.cpp +++ b/src/client_story_posting.cpp @@ -54,7 +54,7 @@ std::int32_t Client::post_story(const std::string& path, bool as_photo, const st req.random_id = story_random_id(); req.period = period; - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); for (const auto& update : std::get_if(&updates.value) ? std::get(updates.value).updates : std::vector>{}) { if (!update) continue; diff --git a/src/client_story_reactions.cpp b/src/client_story_reactions.cpp index a6ae643..870d58e 100644 --- a/src/client_story_reactions.cpp +++ b/src/client_story_reactions.cpp @@ -11,14 +11,14 @@ std::vector Client::read_stories(const PeerRef& chat, std::int32_t fn::stories_readStories req; req.peer = std::make_shared(input_peer(chat)); req.max_id = max_id; - return session_->call(req); + return call_rpc(req); } bool Client::set_all_stories_hidden(bool hidden) { require_session(); fn::stories_toggleAllStoriesHidden req; req.hidden = hidden; - return session_->call(req); + return call_rpc(req); } bool Client::set_peer_stories_hidden(const PeerRef& chat, bool hidden) { @@ -26,7 +26,7 @@ bool Client::set_peer_stories_hidden(const PeerRef& chat, bool hidden) { fn::stories_togglePeerStoriesHidden req; req.peer = std::make_shared(input_peer(chat)); req.hidden = hidden; - return session_->call(req); + return call_rpc(req); } void Client::send_story_reaction(const PeerRef& chat, std::int32_t story_id, const std::string& emoji, @@ -37,7 +37,7 @@ void Client::send_story_reaction(const PeerRef& chat, std::int32_t story_id, con req.peer = std::make_shared(input_peer(chat)); req.story_id = story_id; req.reaction = std::make_shared(tl::Reaction{ty::reactionEmoji{emoji}}); - session_->call(req); + call_rpc(req); } } // namespace cppgram diff --git a/src/client_text.cpp b/src/client_text.cpp index 60a8c68..c36a5c5 100644 --- a/src/client_text.cpp +++ b/src/client_text.cpp @@ -63,7 +63,7 @@ Message Client::send_text(const PeerRef& target, const std::string& text, ParseM for (const auto& e : formatted.entities) entities.push_back(to_tl_entity(e)); req.entities = std::move(entities); } - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, formatted.text, 0)) return *msg; throw std::runtime_error("formatted message was sent but no id was returned by the server"); } @@ -80,7 +80,7 @@ Message Client::edit_text(const PeerRef& target, std::int32_t message_id, const for (const auto& e : formatted.entities) entities.push_back(to_tl_entity(e)); req.entities = std::move(entities); } - const tl::Updates updates = session_->call(req); + const tl::Updates updates = call_rpc(req); if (auto msg = find_sent_message(updates, target, formatted.text, message_id)) return *msg; throw std::runtime_error("edit applied but the server returned no message"); } From 237557a1480d4219dcfe7dd94265dd0759a17dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:48:10 +0000 Subject: [PATCH 06/25] session: passphrase-encrypted session blobs (scrypt + AES-256-GCM) New CGE1 format: scrypt key derivation (N=2^15, r=8, p=1), random salt and nonce per encryption, header authenticated as AAD, bounded KDF parameters on decrypt. Passphrase is read from CPPGRAM_SESSION_PASSPHRASE. Not yet wired into the session file storage; that is a separate step. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- CMakeLists.txt | 1 + include/cppgram/session_crypto.h | 28 ++++++ src/session_crypto.cpp | 133 ++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 +- tests/mtproto/session_crypto_test.cpp | 100 +++++++++++++++++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 include/cppgram/session_crypto.h create mode 100644 src/session_crypto.cpp create mode 100644 tests/mtproto/session_crypto_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 97e16a8..49adff9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,6 +103,7 @@ add_library(cppgram src/client_send.cpp src/client_session.cpp src/session_string.cpp + src/session_crypto.cpp src/convert.cpp src/filters.cpp ) diff --git a/include/cppgram/session_crypto.h b/include/cppgram/session_crypto.h new file mode 100644 index 0000000..6824b04 --- /dev/null +++ b/include/cppgram/session_crypto.h @@ -0,0 +1,28 @@ +// cppgram - passphrase-encrypted session blobs. +// Format "CGE1": magic(4) | log2N(1) | r(1) | p(1) | salt(16) | nonce(12) | tag(16) | ciphertext. +// Key: scrypt(passphrase, salt, N, r, p) -> 32 bytes. Cipher: AES-256-GCM with a fresh random nonce per encryption. +// The header is authenticated as additional data, so changing any header byte fails decryption. +// The passphrase comes from the environment variable named by kSessionPassphraseEnv, never from a file. +#pragma once + +#include +#include +#include +#include + +namespace cppgram::session_crypto { + +inline constexpr std::string_view kSessionPassphraseEnv = "CPPGRAM_SESSION_PASSPHRASE"; +inline constexpr std::string_view kMagic = "CGE1"; + +// Encrypts plaintext. Throws std::invalid_argument on an empty passphrase. +std::vector encrypt(std::string_view passphrase, std::span plaintext); + +// Decrypts a blob. Throws std::runtime_error for a wrong passphrase, any tampering, truncation, or scrypt +// parameters outside the accepted range. All authentication failures give the same message. +std::vector decrypt(std::string_view passphrase, std::span blob); + +// True when the blob starts with the encrypted-session magic. Used to tell encrypted from plain session files. +bool is_encrypted(std::span blob) noexcept; + +} // namespace cppgram::session_crypto diff --git a/src/session_crypto.cpp b/src/session_crypto.cpp new file mode 100644 index 0000000..d22f112 --- /dev/null +++ b/src/session_crypto.cpp @@ -0,0 +1,133 @@ +#include "cppgram/session_crypto.h" + +#include +#include +#include + +#include +#include + +namespace cppgram::session_crypto { +namespace { + +constexpr std::size_t kSaltLen = 16; +constexpr std::size_t kNonceLen = 12; +constexpr std::size_t kTagLen = 16; +constexpr std::size_t kKeyLen = 32; +constexpr std::size_t kHeaderLen = 4 + 3 + kSaltLen + kNonceLen; // magic, params, salt, nonce +constexpr std::size_t kMinBlob = kHeaderLen + kTagLen; + +// Default cost: N = 2^15, r = 8, p = 1 (about 32 MiB of scrypt memory). Accepted range bounds decrypt input. +constexpr std::uint8_t kLog2N = 15; +constexpr std::uint8_t kR = 8; +constexpr std::uint8_t kP = 1; +constexpr std::uint8_t kMinLog2N = 14; +constexpr std::uint8_t kMaxLog2N = 20; +constexpr std::uint8_t kMaxR = 16; +constexpr std::uint8_t kMaxP = 4; + +[[noreturn]] void fail_auth() { + throw std::runtime_error("session decryption failed: wrong passphrase or corrupted data"); +} + +void derive_key(std::string_view passphrase, const std::uint8_t* salt, std::uint8_t log2n, std::uint8_t r, + std::uint8_t p, std::uint8_t* key) { + const std::uint64_t n = std::uint64_t{1} << log2n; + const std::uint64_t memory = 128ull * n * r; + if (EVP_PBE_scrypt(passphrase.data(), passphrase.size(), salt, kSaltLen, n, r, p, memory * 2 + (1u << 20), key, + kKeyLen) != 1) { + throw std::runtime_error("scrypt key derivation failed"); + } +} + +// Secret material is wiped when it leaves scope, including on exceptions. +struct KeyGuard { + std::uint8_t key[kKeyLen]{}; + ~KeyGuard() { OPENSSL_cleanse(key, sizeof key); } +}; + +} // namespace + +std::vector encrypt(std::string_view passphrase, std::span plaintext) { + if (passphrase.empty()) throw std::invalid_argument("session passphrase must not be empty"); + + std::vector out(kHeaderLen + kTagLen + plaintext.size()); + std::memcpy(out.data(), kMagic.data(), 4); + out[4] = kLog2N; + out[5] = kR; + out[6] = kP; + std::uint8_t* salt = out.data() + 7; + std::uint8_t* nonce = salt + kSaltLen; + std::uint8_t* tag = out.data() + kHeaderLen; + std::uint8_t* body = tag + kTagLen; + if (RAND_bytes(salt, kSaltLen) != 1 || RAND_bytes(nonce, kNonceLen) != 1) { + throw std::runtime_error("secure random generation failed"); + } + + KeyGuard guard; + derive_key(passphrase, salt, kLog2N, kR, kP, guard.key); + + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) throw std::runtime_error("cannot create cipher context"); + int len = 0; + bool ok = EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast(kNonceLen), nullptr) == 1 && + EVP_EncryptInit_ex(ctx, nullptr, nullptr, guard.key, nonce) == 1 && + EVP_EncryptUpdate(ctx, nullptr, &len, out.data(), static_cast(kHeaderLen)) == 1; // header as AAD + if (ok && !plaintext.empty()) { + ok = EVP_EncryptUpdate(ctx, body, &len, plaintext.data(), static_cast(plaintext.size())) == 1; + } + if (ok) ok = EVP_EncryptFinal_ex(ctx, body + plaintext.size(), &len) == 1; + if (ok) ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, static_cast(kTagLen), tag) == 1; + EVP_CIPHER_CTX_free(ctx); + if (!ok) throw std::runtime_error("session encryption failed"); + return out; +} + +std::vector decrypt(std::string_view passphrase, std::span blob) { + if (passphrase.empty()) throw std::invalid_argument("session passphrase must not be empty"); + if (blob.size() < kMinBlob || !is_encrypted(blob)) { + throw std::runtime_error("not an encrypted session blob"); + } + const std::uint8_t log2n = blob[4]; + const std::uint8_t r = blob[5]; + const std::uint8_t p = blob[6]; + if (log2n < kMinLog2N || log2n > kMaxLog2N || r == 0 || r > kMaxR || p == 0 || p > kMaxP) { + throw std::runtime_error("encrypted session has unsupported key-derivation parameters"); + } + const std::uint8_t* salt = blob.data() + 7; + const std::uint8_t* nonce = salt + kSaltLen; + const std::uint8_t* tag = blob.data() + kHeaderLen; + const std::uint8_t* body = tag + kTagLen; + const std::size_t body_len = blob.size() - kMinBlob; + + KeyGuard guard; + derive_key(passphrase, salt, log2n, r, p, guard.key); + + std::vector out(body_len); + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) throw std::runtime_error("cannot create cipher context"); + int len = 0; + bool ok = EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast(kNonceLen), nullptr) == 1 && + EVP_DecryptInit_ex(ctx, nullptr, nullptr, guard.key, nonce) == 1 && + EVP_DecryptUpdate(ctx, nullptr, &len, blob.data(), static_cast(kHeaderLen)) == 1; + if (ok && body_len > 0) { + ok = EVP_DecryptUpdate(ctx, out.data(), &len, body, static_cast(body_len)) == 1; + } + if (ok) ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast(kTagLen), const_cast(tag)) == 1; + int final_len = 0; + if (ok) ok = EVP_DecryptFinal_ex(ctx, out.data() + body_len, &final_len) == 1; // fails on a bad tag + EVP_CIPHER_CTX_free(ctx); + if (!ok) { + OPENSSL_cleanse(out.data(), out.size()); + fail_auth(); + } + return out; +} + +bool is_encrypted(std::span blob) noexcept { + return blob.size() >= 4 && std::memcmp(blob.data(), kMagic.data(), 4) == 0; +} + +} // namespace cppgram::session_crypto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d915479..05c1e80 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/session_crypto_test.cpp b/tests/mtproto/session_crypto_test.cpp new file mode 100644 index 0000000..8669d38 --- /dev/null +++ b/tests/mtproto/session_crypto_test.cpp @@ -0,0 +1,100 @@ +#include + +#include +#include +#include +#include +#include + +#include "cppgram/session_crypto.h" + +using namespace cppgram::session_crypto; + +namespace { + +std::vector bytes_of(const std::string& s) { return {s.begin(), s.end()}; } + +} // namespace + +TEST(SessionCrypto, RoundTripRestoresPlaintext) { + const auto plain = bytes_of("auth key bytes and dc id 2"); + const auto blob = encrypt("correct horse battery staple", plain); + EXPECT_TRUE(is_encrypted(blob)); + EXPECT_EQ(decrypt("correct horse battery staple", blob), plain); +} + +TEST(SessionCrypto, EmptyPlaintextRoundTrips) { + const auto blob = encrypt("pw", {}); + EXPECT_TRUE(decrypt("pw", blob).empty()); +} + +TEST(SessionCrypto, CiphertextDoesNotContainPlaintext) { + const auto plain = bytes_of("SECRET-SESSION-MARKER-0123456789"); + const auto blob = encrypt("pw", plain); + const auto hit = std::search(blob.begin(), blob.end(), plain.begin(), plain.end()); + EXPECT_EQ(hit, blob.end()); +} + +TEST(SessionCrypto, EveryEncryptionUsesFreshSaltAndNonce) { + const auto plain = bytes_of("same input"); + const auto a = encrypt("pw", plain); + const auto b = encrypt("pw", plain); + EXPECT_NE(a, b); + // Salt and nonce sit at fixed offsets: magic(4)+params(3), then salt(16), nonce(12). + EXPECT_NE(std::vector(a.begin() + 7, a.begin() + 35), std::vector(b.begin() + 7, b.begin() + 35)); + EXPECT_NE(std::vector(a.begin() + 35, a.begin() + 47), std::vector(b.begin() + 35, b.begin() + 47)); +} + +TEST(SessionCrypto, WrongPassphraseIsRejected) { + const auto blob = encrypt("right", bytes_of("data")); + EXPECT_THROW(decrypt("wrong", blob), std::runtime_error); +} + +TEST(SessionCrypto, EveryByteFlipIsDetected) { + const auto blob = encrypt("pw", bytes_of("integrity matters")); + // Flip one bit at every position: header, salt, nonce, tag and ciphertext must all fail authentication. + for (std::size_t i = 0; i < blob.size(); ++i) { + auto bad = blob; + bad[i] ^= 0x01; + EXPECT_ANY_THROW(decrypt("pw", bad)) << "byte " << i << " not protected"; + } +} + +TEST(SessionCrypto, TruncationIsDetected) { + const auto blob = encrypt("pw", bytes_of("some session data")); + for (std::size_t len : {std::size_t{0}, std::size_t{10}, blob.size() - 1}) { + std::vector cut(blob.begin(), blob.begin() + static_cast(len)); + EXPECT_ANY_THROW(decrypt("pw", cut)) << "length " << len; + } +} + +TEST(SessionCrypto, PlainSessionIsNotMistakenForEncrypted) { + const auto plain = bytes_of("CGS2 plain session file"); + EXPECT_FALSE(is_encrypted(plain)); + EXPECT_THROW(decrypt("pw", plain), std::runtime_error); +} + +TEST(SessionCrypto, OutOfRangeCostParametersAreRejected) { + auto blob = encrypt("pw", bytes_of("x")); + blob[4] = 3; // log2N far too small: would be a downgrade + EXPECT_THROW(decrypt("pw", blob), std::runtime_error); + blob[4] = 40; // log2N far too large: would be a memory-exhaustion request + EXPECT_THROW(decrypt("pw", blob), std::runtime_error); +} + +TEST(SessionCrypto, EmptyPassphraseIsRefused) { + EXPECT_THROW(encrypt("", bytes_of("x")), std::invalid_argument); + EXPECT_THROW(decrypt("", encrypt("pw", bytes_of("x"))), std::invalid_argument); +} + +TEST(SessionCrypto, ErrorMessageDoesNotEchoSecrets) { + const auto blob = encrypt("hunter2", bytes_of("payload")); + try { + decrypt("not-hunter2", blob); + FAIL() << "expected failure"; + } catch (const std::runtime_error& e) { + const std::string msg = e.what(); + EXPECT_EQ(msg.find("hunter2"), std::string::npos); + EXPECT_EQ(msg.find("payload"), std::string::npos); + } +} From 7c9137d467d3a74caa250d8711092c783ed49168 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:53:01 +0000 Subject: [PATCH 07/25] mtproto: SOCKS5 CONNECT with username/password auth IPv4, IPv6 and domain targets, per-operation timeouts, RFC 1928 reply mapping, and a bounded retry that only repeats transient failures. Returns a plain connected fd so an MTProto obfuscated proxy can be added later without changing framing. Not yet wired into Connection; that is a separate step. Tests use a scripted loopback proxy. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- CMakeLists.txt | 1 + include/cppgram/mtproto/socks5.h | 54 ++++++ src/mtproto/socks5.cpp | 254 +++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 +- tests/mtproto/socks5_test.cpp | 283 +++++++++++++++++++++++++++++++ 5 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 include/cppgram/mtproto/socks5.h create mode 100644 src/mtproto/socks5.cpp create mode 100644 tests/mtproto/socks5_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 49adff9..edb2c6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,7 @@ add_library(cppgram src/client_login.cpp src/transfer.cpp src/mtproto/cancel.cpp + src/mtproto/socks5.cpp src/channel_pts.cpp src/client_extra.cpp src/client_more.cpp diff --git a/include/cppgram/mtproto/socks5.h b/include/cppgram/mtproto/socks5.h new file mode 100644 index 0000000..59380e1 --- /dev/null +++ b/include/cppgram/mtproto/socks5.h @@ -0,0 +1,54 @@ +// cppgram - SOCKS5 CONNECT tunnel (RFC 1928) with username/password auth (RFC 1929). +// Produces a connected TCP socket that carries the MTProto byte stream unchanged. The transport layer only needs +// a connected fd, so an MTProto obfuscated proxy can later replace this function without touching the framing code. +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace cppgram::mtproto { + +struct Socks5Auth { + std::string username; // 1..255 bytes + std::string password; // 1..255 bytes +}; + +struct Socks5Options { + std::string proxy_host; + std::uint16_t proxy_port = 1080; + std::optional auth; // unset: no-auth method only + std::chrono::milliseconds timeout{10000}; // applies to connect and to every handshake read/write +}; + +// Failure of a SOCKS5 exchange. reply_code() is the RFC 1928 REP value when the proxy refused the request +// (for example 5 = connection refused), and 0xFF for errors that happened before a reply (network, timeout, auth). +class Socks5Error : public std::runtime_error { +public: + Socks5Error(const std::string& message, std::uint8_t reply = 0xFF, bool transient = false) + : std::runtime_error(message), reply_(reply), transient_(transient) {} + std::uint8_t reply_code() const noexcept { return reply_; } + // True for network trouble, timeouts and general proxy failures. Rejected credentials and refused + // targets are not transient. + bool transient() const noexcept { return transient_; } + +private: + std::uint8_t reply_; + bool transient_; +}; + +// Opens a TCP connection to target_host:target_port through the proxy. target_host may be an IPv4 literal, +// an IPv6 literal, or a domain name (resolved by the proxy). Returns a blocking socket fd owned by the caller. +// Throws std::invalid_argument for a bad target or credentials, and Socks5Error for everything else. +int socks5_connect(const Socks5Options& options, std::string_view target_host, std::uint16_t target_port); + +// Retries socks5_connect for transient failures only (network down, timeout, proxy busy or unreachable). +// Authentication and target refusals are returned immediately. Makes at most `attempts` tries with a fixed +// `pause` between them, so it never loops forever. +int socks5_connect_with_retry(const Socks5Options& options, std::string_view target_host, std::uint16_t target_port, + unsigned attempts, std::chrono::milliseconds pause); + +} // namespace cppgram::mtproto diff --git a/src/mtproto/socks5.cpp b/src/mtproto/socks5.cpp new file mode 100644 index 0000000..53e3920 --- /dev/null +++ b/src/mtproto/socks5.cpp @@ -0,0 +1,254 @@ +#include "cppgram/mtproto/socks5.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace cppgram::mtproto { +namespace { + +constexpr std::uint8_t kVersion = 5; +constexpr std::uint8_t kMethodNone = 0x00; +constexpr std::uint8_t kMethodUserPass = 0x02; +constexpr std::uint8_t kMethodNoneAcceptable = 0xFF; +constexpr std::uint8_t kCmdConnect = 0x01; +constexpr std::uint8_t kAtypIPv4 = 0x01; +constexpr std::uint8_t kAtypDomain = 0x03; +constexpr std::uint8_t kAtypIPv6 = 0x04; + +class Fd { +public: + explicit Fd(int fd = -1) : fd_(fd) {} + ~Fd() { if (fd_ >= 0) ::close(fd_); } + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + Fd& operator=(Fd&& other) noexcept { + if (this != &other) { + if (fd_ >= 0) ::close(fd_); + fd_ = other.fd_; + other.fd_ = -1; + } + return *this; + } + int get() const noexcept { return fd_; } + int release() noexcept { int f = fd_; fd_ = -1; return f; } + +private: + int fd_; +}; + +const char* reply_text(std::uint8_t rep) { + switch (rep) { + case 0x01: return "general SOCKS server failure"; + case 0x02: return "connection not allowed by ruleset"; + case 0x03: return "network unreachable"; + case 0x04: return "host unreachable"; + case 0x05: return "connection refused"; + case 0x06: return "TTL expired"; + case 0x07: return "command not supported"; + case 0x08: return "address type not supported"; + default: return "unknown reply"; + } +} + +void set_socket_timeouts(int fd, std::chrono::milliseconds timeout) { + timeval tv{}; + tv.tv_sec = static_cast(timeout.count() / 1000); + tv.tv_usec = static_cast((timeout.count() % 1000) * 1000); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); +} + +// Connects one resolved address. Returns -1 on failure. The returned socket is blocking with timeouts set. +int connect_one(const addrinfo* ai, std::chrono::milliseconds timeout) { + const int fd = ::socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol); + if (fd < 0) return -1; + const int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + int rc = ::connect(fd, ai->ai_addr, ai->ai_addrlen); + if (rc != 0 && errno != EINPROGRESS) { + ::close(fd); + return -1; + } + if (rc != 0) { + pollfd pfd{fd, POLLOUT, 0}; + if (::poll(&pfd, 1, static_cast(timeout.count())) <= 0) { + ::close(fd); + return -1; + } + int err = 0; + socklen_t len = sizeof err; + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); + if (err != 0) { + ::close(fd); + return -1; + } + } + fcntl(fd, F_SETFL, flags); // back to blocking; the timeouts below bound each read and write + set_socket_timeouts(fd, timeout); + return fd; +} + +void send_all(int fd, const std::vector& data) { + std::size_t sent = 0; + while (sent < data.size()) { + const ssize_t n = ::send(fd, data.data() + sent, data.size() - sent, MSG_NOSIGNAL); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) throw Socks5Error("timed out sending to SOCKS5 proxy", 0xFF, true); + throw Socks5Error("failed sending to SOCKS5 proxy: " + std::string(std::strerror(errno)), 0xFF, true); + } + sent += static_cast(n); + } +} + +void recv_exact(int fd, std::uint8_t* out, std::size_t len) { + std::size_t got = 0; + while (got < len) { + const ssize_t n = ::recv(fd, out + got, len - got, 0); + if (n == 0) throw Socks5Error("SOCKS5 proxy closed the connection during handshake", 0xFF, true); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) throw Socks5Error("timed out waiting for SOCKS5 proxy", 0xFF, true); + throw Socks5Error("failed reading from SOCKS5 proxy: " + std::string(std::strerror(errno)), 0xFF, true); + } + got += static_cast(n); + } +} + +// Encodes the target as SOCKS5 ATYP + address. IPv4 and IPv6 literals are sent as binary; anything else is a domain. +std::vector encode_target(std::string_view host, std::uint16_t port) { + std::vector out; + in_addr v4{}; + in6_addr v6{}; + const std::string h(host); + if (inet_pton(AF_INET, h.c_str(), &v4) == 1) { + out.push_back(kAtypIPv4); + const auto* p = reinterpret_cast(&v4); + out.insert(out.end(), p, p + 4); + } else if (inet_pton(AF_INET6, h.c_str(), &v6) == 1) { + out.push_back(kAtypIPv6); + const auto* p = reinterpret_cast(&v6); + out.insert(out.end(), p, p + 16); + } else { + if (host.empty() || host.size() > 255) { + throw std::invalid_argument("SOCKS5 target host must be 1..255 bytes"); + } + out.push_back(kAtypDomain); + out.push_back(static_cast(host.size())); + out.insert(out.end(), host.begin(), host.end()); + } + out.push_back(static_cast(port >> 8)); + out.push_back(static_cast(port & 0xFF)); + return out; +} + +void check_auth(const Socks5Auth& auth) { + if (auth.username.empty() || auth.username.size() > 255 || auth.password.empty() || auth.password.size() > 255) { + throw std::invalid_argument("SOCKS5 username and password must each be 1..255 bytes"); + } +} + +} // namespace + +int socks5_connect(const Socks5Options& options, std::string_view target_host, std::uint16_t target_port) { + if (options.auth) check_auth(*options.auth); + const auto target = encode_target(target_host, target_port); // validates the target before any network use + if (options.proxy_host.empty()) throw std::invalid_argument("SOCKS5 proxy host is empty"); + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* list = nullptr; + const std::string port = std::to_string(options.proxy_port); + const int gai = getaddrinfo(options.proxy_host.c_str(), port.c_str(), &hints, &list); + if (gai != 0) throw Socks5Error("cannot resolve SOCKS5 proxy: " + std::string(gai_strerror(gai)), 0xFF, true); + + Fd sock; + for (const addrinfo* ai = list; ai != nullptr && sock.get() < 0; ai = ai->ai_next) { + sock = Fd(connect_one(ai, options.timeout)); + } + freeaddrinfo(list); + if (sock.get() < 0) throw Socks5Error("cannot connect to SOCKS5 proxy (refused or timed out)", 0xFF, true); + + // Greeting: offer no-auth, plus username/password when credentials are configured. + std::vector greeting{kVersion}; + if (options.auth) { + greeting.insert(greeting.end(), {2, kMethodNone, kMethodUserPass}); + } else { + greeting.insert(greeting.end(), {1, kMethodNone}); + } + send_all(sock.get(), greeting); + std::uint8_t choice[2]; + recv_exact(sock.get(), choice, 2); + if (choice[0] != kVersion) throw Socks5Error("SOCKS5 proxy sent a wrong version"); + if (choice[1] == kMethodNoneAcceptable) throw Socks5Error("SOCKS5 proxy accepts no offered auth method"); + if (choice[1] == kMethodUserPass) { + if (!options.auth) throw Socks5Error("SOCKS5 proxy requires authentication but none is configured"); + std::vector auth{1, static_cast(options.auth->username.size())}; + auth.insert(auth.end(), options.auth->username.begin(), options.auth->username.end()); + auth.push_back(static_cast(options.auth->password.size())); + auth.insert(auth.end(), options.auth->password.begin(), options.auth->password.end()); + send_all(sock.get(), auth); + std::uint8_t status[2]; + recv_exact(sock.get(), status, 2); + if (status[1] != 0) throw Socks5Error("SOCKS5 username/password rejected"); + } else if (choice[1] != kMethodNone) { + throw Socks5Error("SOCKS5 proxy chose an unsupported auth method"); + } + + // CONNECT request. + std::vector request{kVersion, kCmdConnect, 0}; + request.insert(request.end(), target.begin(), target.end()); + send_all(sock.get(), request); + + // Reply: VER REP RSV ATYP BND.ADDR BND.PORT. The bound address is read only to keep the stream aligned. + std::uint8_t head[4]; + recv_exact(sock.get(), head, 4); + if (head[0] != kVersion) throw Socks5Error("SOCKS5 proxy sent a wrong version in reply"); + if (head[1] != 0) { + throw Socks5Error(std::string("SOCKS5 connect failed: ") + reply_text(head[1]), head[1], + head[1] == 0x01 || head[1] == 0x03 || head[1] == 0x04 || head[1] == 0x06); + } + std::size_t bound_len = 0; + switch (head[3]) { + case kAtypIPv4: bound_len = 4; break; + case kAtypIPv6: bound_len = 16; break; + case kAtypDomain: { + std::uint8_t len = 0; + recv_exact(sock.get(), &len, 1); + bound_len = len; + break; + } + default: throw Socks5Error("SOCKS5 proxy sent an unknown address type in reply"); + } + std::vector bound(bound_len + 2); + recv_exact(sock.get(), bound.data(), bound.size()); + return sock.release(); +} + +int socks5_connect_with_retry(const Socks5Options& options, std::string_view target_host, std::uint16_t target_port, + unsigned attempts, std::chrono::milliseconds pause) { + if (attempts == 0) throw std::invalid_argument("SOCKS5 retry needs at least one attempt"); + for (unsigned i = 1;; ++i) { + try { + return socks5_connect(options, target_host, target_port); + } catch (const Socks5Error& e) { + // Refusals and rejected credentials are final; only transient network trouble is retried. + if (!e.transient() || i >= attempts) throw; + } + std::this_thread::sleep_for(pause); + } +} + +} // namespace cppgram::mtproto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 05c1e80..2fe1df1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/socks5_test.cpp b/tests/mtproto/socks5_test.cpp new file mode 100644 index 0000000..f53825a --- /dev/null +++ b/tests/mtproto/socks5_test.cpp @@ -0,0 +1,283 @@ +// SOCKS5 client against a scripted local proxy (loopback only, no network access needed). +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/socks5.h" + +using namespace cppgram::mtproto; + +namespace { + +void read_exact(int fd, std::uint8_t* out, std::size_t n) { + std::size_t got = 0; + while (got < n) { + const ssize_t r = ::recv(fd, out + got, n - got, 0); + if (r <= 0) throw std::runtime_error("fake proxy: client went away"); + got += static_cast(r); + } +} + +void write_all(int fd, const std::vector& v) { + ::send(fd, v.data(), v.size(), MSG_NOSIGNAL); +} + +// Scripted SOCKS5 server. Serves one connection, then exits. +class FakeProxy { +public: + enum class Mode { Ok, DomainBound, RequireAuth, RejectAuth, NoMethod, RefuseTarget, Hang }; + + explicit FakeProxy(Mode mode) : mode_(mode) { + lfd_ = ::socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + ::bind(lfd_, reinterpret_cast(&addr), sizeof addr); + ::listen(lfd_, 4); + socklen_t len = sizeof addr; + ::getsockname(lfd_, reinterpret_cast(&addr), &len); + port_ = ntohs(addr.sin_port); + thread_ = std::thread([this] { serve(); }); + } + ~FakeProxy() { + if (thread_.joinable()) thread_.join(); + ::close(lfd_); + } + std::uint16_t port() const { return port_; } + void join() { if (thread_.joinable()) thread_.join(); } + + std::uint8_t atyp = 0; + std::string target; + std::uint16_t target_port = 0; + std::string user, pass; + +private: + void serve() { + const int fd = ::accept(lfd_, nullptr, nullptr); + if (fd < 0) return; + std::uint8_t h[2]; + read_exact(fd, h, 2); + std::vector methods(h[1]); + read_exact(fd, methods.data(), methods.size()); + auto offers = [&](std::uint8_t m) { + for (auto x : methods) if (x == m) return true; + return false; + }; + if (mode_ == Mode::NoMethod) { + write_all(fd, {5, 0xFF}); + ::close(fd); + return; + } + const bool want_auth = mode_ == Mode::RequireAuth || mode_ == Mode::RejectAuth; + if (want_auth && !offers(0x02)) { + write_all(fd, {5, 0xFF}); + ::close(fd); + return; + } + write_all(fd, {5, static_cast(want_auth ? 0x02 : 0x00)}); + if (want_auth) { + std::uint8_t v[2]; + read_exact(fd, v, 2); + user.resize(v[1]); + read_exact(fd, reinterpret_cast(user.data()), v[1]); + std::uint8_t pl = 0; + read_exact(fd, &pl, 1); + pass.resize(pl); + read_exact(fd, reinterpret_cast(pass.data()), pl); + const bool reject = mode_ == Mode::RejectAuth; + write_all(fd, {1, static_cast(reject ? 1 : 0)}); + if (reject) { + ::close(fd); + return; + } + } + std::uint8_t req[4]; + read_exact(fd, req, 4); + atyp = req[3]; + if (atyp == 1) { + std::uint8_t a[4]; + read_exact(fd, a, 4); + char buf[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, a, buf, sizeof buf); + target = buf; + } else if (atyp == 4) { + std::uint8_t a[16]; + read_exact(fd, a, 16); + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, a, buf, sizeof buf); + target = buf; + } else { + std::uint8_t len = 0; + read_exact(fd, &len, 1); + target.resize(len); + read_exact(fd, reinterpret_cast(target.data()), len); + } + std::uint8_t p[2]; + read_exact(fd, p, 2); + target_port = static_cast((p[0] << 8) | p[1]); + + if (mode_ == Mode::RefuseTarget) { + write_all(fd, {5, 5, 0, 1, 0, 0, 0, 0, 0, 0}); + } else if (mode_ == Mode::Hang) { + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + } else if (mode_ == Mode::DomainBound) { + std::vector r{5, 0, 0, 3, 8}; + const std::string bound = "bnd.test"; + r.insert(r.end(), bound.begin(), bound.end()); + r.insert(r.end(), {0, 80}); + write_all(fd, r); + } else { + write_all(fd, {5, 0, 0, 1, 0, 0, 0, 0, 0, 0}); + } + ::close(fd); + } + + Mode mode_; + int lfd_ = -1; + std::uint16_t port_ = 0; + std::thread thread_; +}; + +Socks5Options opts_for(const FakeProxy& p) { + Socks5Options o; + o.proxy_host = "127.0.0.1"; + o.proxy_port = p.port(); + o.timeout = std::chrono::milliseconds(2000); + return o; +} + +} // namespace + +TEST(Socks5, Ipv4TargetIsSentAsBinaryAddress) { + FakeProxy proxy(FakeProxy::Mode::Ok); + const int fd = socks5_connect(opts_for(proxy), "203.0.113.7", 443); + ::close(fd); + proxy.join(); + EXPECT_EQ(proxy.atyp, 1); + EXPECT_EQ(proxy.target, "203.0.113.7"); + EXPECT_EQ(proxy.target_port, 443); +} + +TEST(Socks5, DomainTargetIsResolvedByProxy) { + FakeProxy proxy(FakeProxy::Mode::Ok); + ::close(socks5_connect(opts_for(proxy), "dc2.example.test", 443)); + proxy.join(); + EXPECT_EQ(proxy.atyp, 3); + EXPECT_EQ(proxy.target, "dc2.example.test"); +} + +TEST(Socks5, Ipv6TargetIsSentAsBinaryAddress) { + FakeProxy proxy(FakeProxy::Mode::Ok); + ::close(socks5_connect(opts_for(proxy), "2001:db8::1", 443)); + proxy.join(); + EXPECT_EQ(proxy.atyp, 4); + EXPECT_EQ(proxy.target, "2001:db8::1"); +} + +TEST(Socks5, DomainBoundAddressInReplyIsAccepted) { + FakeProxy proxy(FakeProxy::Mode::DomainBound); + ::close(socks5_connect(opts_for(proxy), "203.0.113.9", 80)); + proxy.join(); + EXPECT_EQ(proxy.target, "203.0.113.9"); +} + +TEST(Socks5, UsernamePasswordAuthIsSent) { + FakeProxy proxy(FakeProxy::Mode::RequireAuth); + Socks5Options o = opts_for(proxy); + o.auth = Socks5Auth{"alice", "s3cret"}; + ::close(socks5_connect(o, "203.0.113.7", 443)); + proxy.join(); + EXPECT_EQ(proxy.user, "alice"); + EXPECT_EQ(proxy.pass, "s3cret"); +} + +TEST(Socks5, RejectedCredentialsFailAsNonTransient) { + FakeProxy proxy(FakeProxy::Mode::RejectAuth); + Socks5Options o = opts_for(proxy); + o.auth = Socks5Auth{"alice", "wrong"}; + try { + socks5_connect(o, "203.0.113.7", 443); + FAIL() << "expected rejection"; + } catch (const Socks5Error& e) { + EXPECT_FALSE(e.transient()); + } +} + +TEST(Socks5, ProxyRequiringAuthWithoutCredentialsFails) { + FakeProxy proxy(FakeProxy::Mode::RequireAuth); + EXPECT_THROW(socks5_connect(opts_for(proxy), "203.0.113.7", 443), Socks5Error); +} + +TEST(Socks5, NoAcceptableMethodFails) { + FakeProxy proxy(FakeProxy::Mode::NoMethod); + EXPECT_THROW(socks5_connect(opts_for(proxy), "203.0.113.7", 443), Socks5Error); +} + +TEST(Socks5, RefusedTargetReportsReplyCodeAndIsNotRetried) { + FakeProxy proxy(FakeProxy::Mode::RefuseTarget); + try { + socks5_connect(opts_for(proxy), "203.0.113.7", 443); + FAIL() << "expected refusal"; + } catch (const Socks5Error& e) { + EXPECT_EQ(e.reply_code(), 5); + EXPECT_FALSE(e.transient()); + } +} + +TEST(Socks5, SilentProxyTimesOutAsTransient) { + FakeProxy proxy(FakeProxy::Mode::Hang); + Socks5Options o = opts_for(proxy); + o.timeout = std::chrono::milliseconds(300); + const auto start = std::chrono::steady_clock::now(); + try { + socks5_connect(o, "203.0.113.7", 443); + FAIL() << "expected timeout"; + } catch (const Socks5Error& e) { + EXPECT_TRUE(e.transient()); + } + const auto took = std::chrono::steady_clock::now() - start; + EXPECT_LT(took, std::chrono::milliseconds(1400)); + proxy.join(); +} + +TEST(Socks5, InvalidInputsAreRejectedBeforeAnyNetworkUse) { + Socks5Options o; + o.proxy_host = "127.0.0.1"; + o.proxy_port = 1; // nothing listens here; the error must come from validation, not from connecting + EXPECT_THROW(socks5_connect(o, "", 443), std::invalid_argument); + EXPECT_THROW(socks5_connect(o, std::string(256, 'a'), 443), std::invalid_argument); + o.auth = Socks5Auth{"", "x"}; + EXPECT_THROW(socks5_connect(o, "203.0.113.7", 443), std::invalid_argument); + o.auth = Socks5Auth{std::string(256, 'u'), "x"}; + EXPECT_THROW(socks5_connect(o, "203.0.113.7", 443), std::invalid_argument); +} + +TEST(Socks5, RetryIsBoundedAndGivesUpOnTransientFailure) { + Socks5Options o; + o.proxy_host = "127.0.0.1"; + o.proxy_port = 1; // refused every time + o.timeout = std::chrono::milliseconds(200); + try { + socks5_connect_with_retry(o, "203.0.113.7", 443, 3, std::chrono::milliseconds(10)); + FAIL() << "expected failure"; + } catch (const Socks5Error& e) { + EXPECT_TRUE(e.transient()); + } +} + +TEST(Socks5, RetryRejectsZeroAttempts) { + Socks5Options o; + o.proxy_host = "127.0.0.1"; + EXPECT_THROW(socks5_connect_with_retry(o, "203.0.113.7", 443, 0, std::chrono::milliseconds(1)), + std::invalid_argument); +} From c7669fef4c1618b11b1b640a85960fd041b98d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:06:16 +0000 Subject: [PATCH 08/25] session: encrypted session files via CPPGRAM_SESSION_PASSPHRASE When the passphrase env var is set, saved session files are wrapped in the CGE1 envelope (scrypt + AES-256-GCM). Encrypted files without a passphrase are refused with SessionCorruptError. Plain files still load and are encrypted on the next save (migration). SQLite session storage and session strings are not encrypted by this change. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- src/mtproto/session.cpp | 54 ++++++-- tests/CMakeLists.txt | 2 +- tests/mtproto/session_encrypted_test.cpp | 151 +++++++++++++++++++++++ 3 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 tests/mtproto/session_encrypted_test.cpp diff --git a/src/mtproto/session.cpp b/src/mtproto/session.cpp index c0fa939..84d4c7b 100644 --- a/src/mtproto/session.cpp +++ b/src/mtproto/session.cpp @@ -15,6 +15,10 @@ #include "cppgram/mtproto/schema/api.h" #include "../secure_file.h" +#include "cppgram/session_crypto.h" + +#include +#include namespace cppgram::mtproto { @@ -95,13 +99,17 @@ void throw_rpc_error(std::int32_t code, const std::string& message) { } -bool load_session_file(const std::string& path, SessionData& out) { - std::ifstream in(path, std::ios::binary); - if (!in) { - return false; - } - const Bytes file((std::istreambuf_iterator(in)), std::istreambuf_iterator()); +namespace { + +// Passphrase for encrypted session files. Read from the environment only; empty counts as unset. +std::optional session_passphrase() { + const char* value = std::getenv(std::string(session_crypto::kSessionPassphraseEnv).c_str()); + if (value == nullptr || *value == '\0') return std::nullopt; + return std::string(value); +} +// Parses a plain CGS2 or CGS1 image into out. +void parse_session_image(const Bytes& file, const std::string& path, SessionData& out) { const bool v2 = file.size() == kSessionBodySize + 4 && std::memcmp(file.data(), kSessionMagic, 4) == 0; const bool v1 = file.size() == kSessionBodySize && std::memcmp(file.data(), kLegacySessionMagic, 4) == 0; if (!v2 && !v1) { @@ -125,6 +133,33 @@ bool load_session_file(const std::string& path, SessionData& out) { out.time_offset = offset; out.server_salt = salt; out.auth_key.assign(file.begin() + 28, file.begin() + 28 + 256); +} + +} // namespace + +bool load_session_file(const std::string& path, SessionData& out) { + std::ifstream in(path, std::ios::binary); + if (!in) { + return false; + } + const Bytes file((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (session_crypto::is_encrypted(file)) { + // Encrypted file: the passphrase must come from the environment. Plain files are still accepted, and + // they are encrypted the next time they are saved while a passphrase is set. + const auto passphrase = session_passphrase(); + if (!passphrase) { + throw SessionCorruptError("session file is encrypted; set CPPGRAM_SESSION_PASSPHRASE to open it: " + path); + } + Bytes image; + try { + image = session_crypto::decrypt(*passphrase, file); + } catch (const std::runtime_error& e) { + throw SessionCorruptError(std::string(e.what()) + ": " + path); + } + parse_session_image(image, path, out); + return true; + } + parse_session_image(file, path, out); return true; } @@ -140,6 +175,11 @@ void save_session_file(const std::string& path, const SessionData& data) { put_le64(image, data.server_salt); image.insert(image.end(), data.auth_key.begin(), data.auth_key.end()); put_le32(image, crc32(image)); + // With a passphrase set, the whole CGS2 image is wrapped in an authenticated CGE1 envelope. + Bytes payload = image; + if (const auto passphrase = session_passphrase()) { + payload = session_crypto::encrypt(*passphrase, image); + } // Crash-safe replace: write a private temp file, fsync it, then rename over the old session. // A crash at any point leaves either the old session or the new one, never a half-written file. const std::string tmp = path + ".tmp"; @@ -149,7 +189,7 @@ void save_session_file(const std::string& path, const SessionData& data) { if (!out) { throw std::runtime_error("cannot open session temp file for writing: " + tmp); } - out.write(reinterpret_cast(image.data()), static_cast(image.size())); + out.write(reinterpret_cast(payload.data()), static_cast(payload.size())); out.flush(); if (!out) { std::remove(tmp.c_str()); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2fe1df1..f991a17 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/session_encrypted_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/session_encrypted_test.cpp b/tests/mtproto/session_encrypted_test.cpp new file mode 100644 index 0000000..1100e5e --- /dev/null +++ b/tests/mtproto/session_encrypted_test.cpp @@ -0,0 +1,151 @@ +// Encrypted session files: passphrase from CPPGRAM_SESSION_PASSPHRASE, plain-file migration, tamper detection. +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/session.h" + +using namespace cppgram::mtproto; + +namespace { + +std::string temp_file(const std::string& name) { + const auto dir = std::filesystem::temp_directory_path() / "cppgram_enc_test"; + std::filesystem::create_directories(dir); + const auto path = (dir / name).string(); + std::remove(path.c_str()); + return path; +} + +void set_passphrase(const char* value) { + if (value == nullptr) { + ::unsetenv("CPPGRAM_SESSION_PASSPHRASE"); + } else { + ::setenv("CPPGRAM_SESSION_PASSPHRASE", value, 1); + } +} + +SessionData sample() { + SessionData d; + d.dc_id = 2; + d.time_offset = -12; + d.server_salt = 0x1122334455667788ull; + d.auth_key.assign(256, 0xAB); + return d; +} + +std::vector read_bytes(const std::string& path) { + std::ifstream in(path, std::ios::binary); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +// Guarantees the environment is clean even when an assertion fails. +struct EnvGuard { + ~EnvGuard() { set_passphrase(nullptr); } +}; + +} // namespace + +TEST(EncryptedSession, RoundTripWithPassphrase) { + EnvGuard guard; + set_passphrase("long test passphrase"); + const auto path = temp_file("roundtrip.session"); + save_session_file(path, sample()); + SessionData loaded; + ASSERT_TRUE(load_session_file(path, loaded)); + EXPECT_EQ(loaded.dc_id, 2); + EXPECT_EQ(loaded.time_offset, -12); + EXPECT_EQ(loaded.server_salt, 0x1122334455667788ull); + EXPECT_EQ(loaded.auth_key, sample().auth_key); +} + +TEST(EncryptedSession, FileOnDiskHoldsNoPlaintextKeyMaterial) { + EnvGuard guard; + set_passphrase("pw-for-disk-check"); + const auto path = temp_file("nosecret.session"); + save_session_file(path, sample()); + const auto bytes = read_bytes(path); + ASSERT_GE(bytes.size(), 4u); + EXPECT_EQ(std::string(bytes.begin(), bytes.begin() + 4), "CGE1"); + // A 256-byte key of 0xAB would appear as a long run of 0xAB; it must not be visible on disk. + std::size_t run = 0, longest = 0; + for (auto b : bytes) { + run = b == 0xAB ? run + 1 : 0; + longest = std::max(longest, run); + } + EXPECT_LT(longest, 8u); +} + +TEST(EncryptedSession, EncryptedFileNeedsPassphrase) { + EnvGuard guard; + const auto path = temp_file("needs-pass.session"); + set_passphrase("pw"); + save_session_file(path, sample()); + set_passphrase(nullptr); + SessionData out; + EXPECT_THROW(load_session_file(path, out), SessionCorruptError); +} + +TEST(EncryptedSession, WrongPassphraseIsCorruptNotSilent) { + EnvGuard guard; + const auto path = temp_file("wrong-pass.session"); + set_passphrase("right"); + save_session_file(path, sample()); + set_passphrase("wrong"); + SessionData out; + EXPECT_THROW(load_session_file(path, out), SessionCorruptError); +} + +TEST(EncryptedSession, TamperedFileIsRejected) { + EnvGuard guard; + const auto path = temp_file("tamper.session"); + set_passphrase("pw"); + save_session_file(path, sample()); + auto bytes = read_bytes(path); + bytes[bytes.size() / 2] ^= 0x40; + { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + SessionData out; + EXPECT_THROW(load_session_file(path, out), SessionCorruptError); +} + +TEST(EncryptedSession, PlainFileIsReadAndMigratedOnNextSave) { + EnvGuard guard; + const auto path = temp_file("migrate.session"); + set_passphrase(nullptr); + save_session_file(path, sample()); // plain CGS2 + const auto plain_bytes = read_bytes(path); + ASSERT_GE(plain_bytes.size(), 4u); + EXPECT_EQ(std::string(plain_bytes.begin(), plain_bytes.begin() + 4), "CGS2"); + + set_passphrase("migrate-pass"); + SessionData loaded; + ASSERT_TRUE(load_session_file(path, loaded)); // plain files still open with a passphrase set + EXPECT_EQ(loaded.auth_key, sample().auth_key); + + save_session_file(path, loaded); // the next save encrypts + const auto bytes = read_bytes(path); + EXPECT_EQ(std::string(bytes.begin(), bytes.begin() + 4), "CGE1"); + SessionData again; + ASSERT_TRUE(load_session_file(path, again)); + EXPECT_EQ(again.auth_key, sample().auth_key); +} + +TEST(EncryptedSession, EncryptedFileIsOwnerReadableOnly) { + EnvGuard guard; + set_passphrase("perm-pass"); + const auto path = temp_file("perm.session"); + save_session_file(path, sample()); + struct stat st{}; + ASSERT_EQ(::stat(path.c_str(), &st), 0); + EXPECT_EQ(st.st_mode & 0777, 0600u); +} From b114a3da0602faeb364cbe1d730e2e6ce1b806ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:06:24 +0000 Subject: [PATCH 09/25] docs: encrypted session files, backup and recovery Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- guides/README.md | 1 + guides/SESSION_ENCRYPTION.md | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 guides/SESSION_ENCRYPTION.md diff --git a/guides/README.md b/guides/README.md index 5250ee4..91c54dc 100644 --- a/guides/README.md +++ b/guides/README.md @@ -13,3 +13,4 @@ | [BENCHMARKS.md](BENCHMARKS.md) | Measured local numbers and environment | | [VERSIONING.md](VERSIONING.md) | Semver policy and API compatibility rules | | [MIGRATION.md](MIGRATION.md) | What changed for existing code and data | +- [Encrypted session files](SESSION_ENCRYPTION.md): passphrase, backup, recovery, limits. diff --git a/guides/SESSION_ENCRYPTION.md b/guides/SESSION_ENCRYPTION.md new file mode 100644 index 0000000..57a2e33 --- /dev/null +++ b/guides/SESSION_ENCRYPTION.md @@ -0,0 +1,44 @@ +# Encrypted session files + +Scope: file-based sessions (`session_path` that does not end in `.sqlite`, `.sqlite3` or `.db`). +Not covered yet: SQLite session storage and `export_session_string()`. Both hold the auth key in plain form. + +## How it works + +- Set `CPPGRAM_SESSION_PASSPHRASE` in the environment of the process. Nothing is read from a file. +- When it is set, every save writes a `CGE1` envelope: `magic | log2N | r | p | salt(16) | nonce(12) | tag(16) | ciphertext`. +- Key: scrypt (N=2^15, r=8, p=1) over the passphrase and a random 16-byte salt, giving a 32-byte key. +- Cipher: AES-256-GCM with a fresh random 12-byte nonce on every save. The header is authenticated as additional data. +- The KDF parameters are checked on load (log2N 14..20, r 1..16, p 1..4). Values outside that range are refused. + +## Behaviour + +| File on disk | Passphrase set | Result | +|---|---|---| +| `CGE1` | yes, correct | opens | +| `CGE1` | yes, wrong or tampered | `SessionCorruptError`, file is not changed | +| `CGE1` | no | `SessionCorruptError`, file is not changed | +| `CGS2`/`CGS1` (plain) | yes | opens, and is encrypted on the next save (migration) | +| `CGS2`/`CGS1` (plain) | no | opens as before | + +Failures never fall back to a new login. A corrupt session is reported, not replaced. + +## Passphrase requirements + +- Use a long random passphrase (at least 20 characters from a password manager). scrypt slows guessing but does not make a weak passphrase safe. +- Do not put the passphrase in a file that sits next to the session. Keep it in a secret manager, or pass it when the service starts (systemd `EnvironmentFile` with mode 0600, owned by the service user). +- Do not pass the passphrase on the command line. Command lines show up in `ps` and in shell history. +- The library never logs the passphrase, the key, or the auth key. + +## Backup and recovery + +- Back up the session file and the passphrase separately. The file alone is unusable without the passphrase. The passphrase alone is useless without the file. +- A backup of an encrypted session is still a stolen-session risk if the passphrase is also stolen. Store both in different places. +- To recover: restore the file with mode 0600, set the same passphrase, start the client. If the passphrase is lost, the session cannot be decrypted. Log in again through a new session and delete the old one from Telegram's "Active sessions" list. +- Passphrase rotation is not implemented in this version. To change it, log in again with a new session file under the new passphrase, then delete the old session in Telegram's "Active sessions" list. + +## Limits + +- scrypt cost is fixed at N=2^15 for new files. Files written by a future version with other parameters still open, as long as they are within the accepted range. +- There is no key escrow. A lost passphrase means a lost session. +- Encryption protects the file at rest. It does not protect a running process whose memory can be read by the same user. From cbfe86557d9e43dc65c9d9cb1a19725ef87a9266 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:14:22 +0000 Subject: [PATCH 10/25] client: optional SOCKS5 proxy for the main DC connection ClientOptions::socks5 routes the DC connection through a SOCKS5 proxy. Connection::connect_via_socks5 throws ProxyError; rejected credentials and refused targets are not retried, transient failures use the existing bounded retry. CDN downloads still connect directly. Tested against a scripted loopback proxy. Live proxy testing is not done. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- include/cppgram/client.h | 3 +++ include/cppgram/mtproto/connection.h | 13 +++++++++++++ src/client.cpp | 9 ++++++++- src/mtproto/connection.cpp | 18 ++++++++++++++++++ tests/mtproto/socks5_test.cpp | 27 +++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/include/cppgram/client.h b/include/cppgram/client.h index 7b59348..0414c6f 100644 --- a/include/cppgram/client.h +++ b/include/cppgram/client.h @@ -17,6 +17,7 @@ #include "cppgram/convert.h" #include "cppgram/filters.h" #include "cppgram/mtproto/retry.h" +#include "cppgram/mtproto/socks5.h" #include "cppgram/mtproto/connection.h" #include "cppgram/mtproto/schema/api.h" #include "cppgram/mtproto/session.h" @@ -36,6 +37,8 @@ struct ClientOptions { std::string session_path = "cppgram.session"; // Base64 session from export_session_string(). Used only when no saved session exists yet. std::string session_string; + // SOCKS5 proxy for the main DC connection. Unset: direct TCP. CDN downloads still connect directly. + std::optional socks5; std::string device_model = "cppgram"; std::string system_version = "linux"; std::string app_version = "0.1.0"; diff --git a/include/cppgram/mtproto/connection.h b/include/cppgram/mtproto/connection.h index c0d8fbd..7f6846a 100644 --- a/include/cppgram/mtproto/connection.h +++ b/include/cppgram/mtproto/connection.h @@ -4,6 +4,7 @@ #include #include +#include "cppgram/mtproto/socks5.h" #include "cppgram/mtproto/transport.h" namespace cppgram::mtproto { @@ -18,6 +19,16 @@ class ConnectionTimeout : public ConnectionError { using ConnectionError::ConnectionError; }; +// A SOCKS5 proxy refused or failed the tunnel. transient() is false for rejected credentials and refused targets. +class ProxyError : public ConnectionError { +public: + ProxyError(const std::string& message, bool transient) : ConnectionError(message), transient_(transient) {} + bool transient() const noexcept { return transient_; } + +private: + bool transient_; +}; + // Anything that can exchange whole MTProto packets (already decrypted or plain, depending on the layer). class PacketChannel { public: @@ -34,6 +45,8 @@ class Connection : public PacketChannel { Connection& operator=(const Connection&) = delete; void connect(const std::string& ipv4, std::uint16_t port); + // Connects to ipv4:port through a SOCKS5 proxy. Failures throw ProxyError. + void connect_via_socks5(const Socks5Options& proxy, const std::string& ipv4, std::uint16_t port); // Receive timeout in milliseconds (0 = block forever). Expiry throws ConnectionTimeout. void set_receive_timeout(int milliseconds); void close() noexcept; diff --git a/src/client.cpp b/src/client.cpp index a1b4bf7..bdd41e1 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -156,9 +156,16 @@ void Client::connect() { mtproto::validate_retry_policy(options_.retry); for (int attempt = 1;; ++attempt) { try { - connection_->connect(options_.dc_ip, options_.dc_port); + if (options_.socks5) { + connection_->connect_via_socks5(*options_.socks5, options_.dc_ip, options_.dc_port); + } else { + connection_->connect(options_.dc_ip, options_.dc_port); + } break; } catch (const mtproto::ConnectionError& e) { + // Rejected proxy credentials and refused targets are final; only transient failures are retried. + const auto* proxy = dynamic_cast(&e); + if (proxy != nullptr && !proxy->transient()) throw; if (!mtproto::may_retry(options_.retry, attempt)) throw; const auto delay = mtproto::backoff_delay(options_.retry, attempt, client_jitter01()); debug_log(debug, std::string("tcp: connect failed (") + e.what() + "), retrying"); diff --git a/src/mtproto/connection.cpp b/src/mtproto/connection.cpp index 57bbe6f..dd2c6c6 100644 --- a/src/mtproto/connection.cpp +++ b/src/mtproto/connection.cpp @@ -7,6 +7,7 @@ #include #include +#include #include namespace cppgram::mtproto { @@ -41,6 +42,23 @@ void Connection::connect(const std::string& ipv4, std::uint16_t port) { transport_ = Transport(transport_.kind()); } +void Connection::connect_via_socks5(const Socks5Options& proxy, const std::string& ipv4, std::uint16_t port) { + close(); + int fd = -1; + try { + fd = socks5_connect(proxy, ipv4, port); + } catch (const Socks5Error& e) { + throw ProxyError(e.what(), e.transient()); + } catch (const std::invalid_argument& e) { + throw ProxyError(e.what(), false); + } + fd_ = fd; + int one = 1; + ::setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + init_sent_ = false; + transport_ = Transport(transport_.kind()); +} + void Connection::close() noexcept { if (fd_ >= 0) { ::close(fd_); diff --git a/tests/mtproto/socks5_test.cpp b/tests/mtproto/socks5_test.cpp index f53825a..29f9fb0 100644 --- a/tests/mtproto/socks5_test.cpp +++ b/tests/mtproto/socks5_test.cpp @@ -14,6 +14,7 @@ #include #include "cppgram/mtproto/socks5.h" +#include "cppgram/mtproto/connection.h" using namespace cppgram::mtproto; @@ -281,3 +282,29 @@ TEST(Socks5, RetryRejectsZeroAttempts) { EXPECT_THROW(socks5_connect_with_retry(o, "203.0.113.7", 443, 0, std::chrono::milliseconds(1)), std::invalid_argument); } + +TEST(Socks5Connection, ConnectionTunnelsThroughProxyAndReportsRejectionAsFinal) { + { + FakeProxy proxy(FakeProxy::Mode::Ok); + cppgram::mtproto::Connection conn(cppgram::mtproto::TransportKind::Intermediate); + conn.connect_via_socks5(opts_for(proxy), "203.0.113.7", 443); + EXPECT_TRUE(conn.is_open()); + conn.close(); + proxy.join(); + EXPECT_EQ(proxy.target, "203.0.113.7"); + EXPECT_EQ(proxy.target_port, 443); + } + { + FakeProxy proxy(FakeProxy::Mode::RejectAuth); + Socks5Options o = opts_for(proxy); + o.auth = Socks5Auth{"alice", "wrong"}; + cppgram::mtproto::Connection conn(cppgram::mtproto::TransportKind::Intermediate); + try { + conn.connect_via_socks5(o, "203.0.113.7", 443); + FAIL() << "expected rejection"; + } catch (const cppgram::mtproto::ProxyError& e) { + EXPECT_FALSE(e.transient()); + } + EXPECT_FALSE(conn.is_open()); + } +} From 915916c98262ef5d44010f9da562f02a080cdf11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:21:31 +0000 Subject: [PATCH 11/25] storage: encrypted auth key in the SQLite session store With CPPGRAM_SESSION_PASSPHRASE set, the key column holds a CGE1 envelope. Plain rows still load and are encrypted on next save. Encrypted rows without the passphrase, or with the wrong one, throw SessionCorruptError. Tests check that the raw key bytes are absent from the database file. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- include/cppgram/session_crypto.h | 5 + src/session_crypto.cpp | 7 ++ src/storage.cpp | 27 +++++- tests/CMakeLists.txt | 2 +- tests/mtproto/sqlite_encrypted_test.cpp | 120 ++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/mtproto/sqlite_encrypted_test.cpp diff --git a/include/cppgram/session_crypto.h b/include/cppgram/session_crypto.h index 6824b04..903b60a 100644 --- a/include/cppgram/session_crypto.h +++ b/include/cppgram/session_crypto.h @@ -6,7 +6,9 @@ #pragma once #include +#include #include +#include #include #include @@ -22,6 +24,9 @@ std::vector encrypt(std::string_view passphrase, std::span decrypt(std::string_view passphrase, std::span blob); +// Passphrase from kSessionPassphraseEnv, or nullopt when unset or empty. +std::optional passphrase_from_env(); + // True when the blob starts with the encrypted-session magic. Used to tell encrypted from plain session files. bool is_encrypted(std::span blob) noexcept; diff --git a/src/session_crypto.cpp b/src/session_crypto.cpp index d22f112..9ac8829 100644 --- a/src/session_crypto.cpp +++ b/src/session_crypto.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -126,6 +127,12 @@ std::vector decrypt(std::string_view passphrase, std::span passphrase_from_env() { + const char* value = std::getenv(std::string(kSessionPassphraseEnv).c_str()); + if (value == nullptr || *value == '\0') return std::nullopt; + return std::string(value); +} + bool is_encrypted(std::span blob) noexcept { return blob.size() >= 4 && std::memcmp(blob.data(), kMagic.data(), 4) == 0; } diff --git a/src/storage.cpp b/src/storage.cpp index 8f22692..b0bfc9e 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -3,10 +3,12 @@ #include #include "cppgram/mtproto/session.h" +#include "cppgram/session_crypto.h" #include "secure_file.h" #include +#include namespace cppgram { @@ -107,9 +109,25 @@ bool SqliteStorage::load_session(mtproto::SessionData& out) const { out.dc_id = sqlite3_column_int(stmt, 0); const auto* blob = static_cast(sqlite3_column_blob(stmt, 1)); const int size = sqlite3_column_bytes(stmt, 1); - out.auth_key.assign(blob, blob + size); + std::vector stored(blob, blob + size); out.server_salt = static_cast(sqlite3_column_int64(stmt, 2)); out.time_offset = sqlite3_column_int64(stmt, 3); + if (session_crypto::is_encrypted(stored)) { + // The key is stored as a CGE1 envelope. The passphrase must come from the environment. + const auto passphrase = session_crypto::passphrase_from_env(); + if (!passphrase) { + sqlite3_finalize(stmt); + throw mtproto::SessionCorruptError("session store holds an encrypted key; set CPPGRAM_SESSION_PASSPHRASE"); + } + try { + out.auth_key = session_crypto::decrypt(*passphrase, stored); + } catch (const std::runtime_error& e) { + sqlite3_finalize(stmt); + throw mtproto::SessionCorruptError(e.what()); + } + } else { + out.auth_key = std::move(stored); + } found = out.auth_key.size() == 256; } sqlite3_finalize(stmt); @@ -124,7 +142,12 @@ void SqliteStorage::save_session(const mtproto::SessionData& data) { -1, &stmt, nullptr), db_, "prepare save_session"); sqlite3_bind_int(stmt, 1, data.dc_id); - sqlite3_bind_blob(stmt, 2, data.auth_key.data(), static_cast(data.auth_key.size()), SQLITE_TRANSIENT); + // With a passphrase set, the key is stored as a CGE1 envelope; otherwise as the raw 256 bytes. + std::vector stored = data.auth_key; + if (const auto passphrase = session_crypto::passphrase_from_env()) { + stored = session_crypto::encrypt(*passphrase, data.auth_key); + } + sqlite3_bind_blob(stmt, 2, stored.data(), static_cast(stored.size()), SQLITE_TRANSIENT); sqlite3_bind_int64(stmt, 3, static_cast(data.server_salt)); sqlite3_bind_int64(stmt, 4, data.time_offset); const int rc = sqlite3_step(stmt); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f991a17..c7bff89 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/session_encrypted_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/sqlite_encrypted_test.cpp b/tests/mtproto/sqlite_encrypted_test.cpp new file mode 100644 index 0000000..a6af02b --- /dev/null +++ b/tests/mtproto/sqlite_encrypted_test.cpp @@ -0,0 +1,120 @@ +// SQLite session store with an encrypted auth key (CGE1 envelope in the auth_key column). +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/session.h" +#include "cppgram/storage.h" + +using namespace cppgram; + +namespace { + +std::string fresh_db(const std::string& name) { + const auto dir = std::filesystem::temp_directory_path() / "cppgram_sqlite_enc_test"; + std::filesystem::create_directories(dir); + const auto path = (dir / name).string(); + for (const char* suffix : {"", "-wal", "-shm"}) std::remove((path + suffix).c_str()); + return path; +} + +void set_passphrase(const char* value) { + if (value == nullptr) { + ::unsetenv("CPPGRAM_SESSION_PASSPHRASE"); + } else { + ::setenv("CPPGRAM_SESSION_PASSPHRASE", value, 1); + } +} + +struct EnvGuard { + ~EnvGuard() { set_passphrase(nullptr); } +}; + +mtproto::SessionData sample() { + mtproto::SessionData d; + d.dc_id = 4; + d.time_offset = 7; + d.server_salt = 42; + d.auth_key.assign(256, 0xCD); + return d; +} + +bool file_contains_run(const std::string& path, std::uint8_t value, std::size_t run_len) { + std::ifstream in(path, std::ios::binary); + const std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::size_t run = 0; + for (char c : bytes) { + run = static_cast(c) == value ? run + 1 : 0; + if (run >= run_len) return true; + } + return false; +} + +} // namespace + +TEST(SqliteEncrypted, KeyIsStoredEncryptedAndRoundTrips) { + EnvGuard guard; + const auto path = fresh_db("enc.sqlite"); + set_passphrase("sqlite-pass-value"); + { + SqliteStorage store(path); + store.save_session(sample()); + } + EXPECT_FALSE(file_contains_run(path, 0xCD, 16)) << "auth key visible in the database file"; + SqliteStorage store(path); + mtproto::SessionData loaded; + ASSERT_TRUE(store.load_session(loaded)); + EXPECT_EQ(loaded.auth_key, sample().auth_key); + EXPECT_EQ(loaded.dc_id, 4); + EXPECT_EQ(loaded.server_salt, 42u); +} + +TEST(SqliteEncrypted, EncryptedKeyNeedsPassphrase) { + EnvGuard guard; + const auto path = fresh_db("needs.sqlite"); + set_passphrase("pw1"); + { SqliteStorage store(path); store.save_session(sample()); } + set_passphrase(nullptr); + SqliteStorage store(path); + mtproto::SessionData out; + EXPECT_THROW(store.load_session(out), mtproto::SessionCorruptError); +} + +TEST(SqliteEncrypted, WrongPassphraseIsCorrupt) { + EnvGuard guard; + const auto path = fresh_db("wrong.sqlite"); + set_passphrase("right-pass"); + { SqliteStorage store(path); store.save_session(sample()); } + set_passphrase("wrong-pass"); + SqliteStorage store(path); + mtproto::SessionData out; + EXPECT_THROW(store.load_session(out), mtproto::SessionCorruptError); +} + +TEST(SqliteEncrypted, PlainKeyStillLoadsAndIsEncryptedOnNextSave) { + EnvGuard guard; + const auto path = fresh_db("migrate.sqlite"); + set_passphrase(nullptr); + { SqliteStorage store(path); store.save_session(sample()); } + EXPECT_TRUE(file_contains_run(path, 0xCD, 16)) << "plain store should hold the raw key"; + + set_passphrase("migrate-pass"); + { + SqliteStorage store(path); + mtproto::SessionData loaded; + ASSERT_TRUE(store.load_session(loaded)); + EXPECT_EQ(loaded.auth_key, sample().auth_key); + store.save_session(loaded); + } + EXPECT_FALSE(file_contains_run(path, 0xCD, 16)); + SqliteStorage store(path); + mtproto::SessionData again; + ASSERT_TRUE(store.load_session(again)); + EXPECT_EQ(again.auth_key, sample().auth_key); +} From 160838afae498731271d187f3ecc11f10e1857ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:37:47 +0000 Subject: [PATCH 12/25] session string: passphrase-encrypted export and import With CPPGRAM_SESSION_PASSPHRASE set, encode_session_string returns a "cge1:" CGE1 envelope. Decoding an encrypted string without the passphrase or with the wrong one throws instead of reporting "no session". Plain strings are unchanged. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- src/session_string.cpp | 28 +++++- tests/CMakeLists.txt | 2 +- .../mtproto/session_string_encrypted_test.cpp | 89 +++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/mtproto/session_string_encrypted_test.cpp diff --git a/src/session_string.cpp b/src/session_string.cpp index 106f576..f123843 100644 --- a/src/session_string.cpp +++ b/src/session_string.cpp @@ -1,6 +1,10 @@ #include "cppgram/session_string.h" #include +#include +#include + +#include "cppgram/session_crypto.h" namespace cppgram { @@ -65,6 +69,7 @@ bool base64_decode(const std::string& text, mtproto::Bytes& out) { // Layout: version(1)=1 | dc_id(4 LE) | time_offset(8 LE) | server_salt(8 LE) | auth_key(256). constexpr std::uint8_t kVersion = 1; constexpr std::size_t kSize = 1 + 4 + 8 + 8 + 256; +constexpr std::string_view kEncryptedPrefix = "cge1:"; void put_le(mtproto::Bytes& out, std::uint64_t v, int n) { for (int i = 0; i < n; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xFF)); @@ -86,12 +91,33 @@ std::string encode_session_string(const mtproto::SessionData& data) { put_le(raw, static_cast(data.time_offset), 8); put_le(raw, data.server_salt, 8); raw.insert(raw.end(), data.auth_key.begin(), data.auth_key.end()); + // With CPPGRAM_SESSION_PASSPHRASE set, the string is an encrypted CGE1 envelope behind a "cge1:" prefix. + if (const auto passphrase = session_crypto::passphrase_from_env()) { + const mtproto::Bytes sealed = session_crypto::encrypt(*passphrase, raw); + return std::string(kEncryptedPrefix) + base64_encode(sealed); + } return base64_encode(raw); } bool decode_session_string(const std::string& text, mtproto::SessionData& out) { mtproto::Bytes raw; - if (!base64_decode(text, raw) || raw.size() != kSize || raw[0] != kVersion) return false; + if (text.rfind(kEncryptedPrefix, 0) == 0) { + // Encrypted strings cannot be read without the passphrase. That is an error, not "no session". + const auto passphrase = session_crypto::passphrase_from_env(); + if (!passphrase) { + throw std::runtime_error("session string is encrypted; set CPPGRAM_SESSION_PASSPHRASE to use it"); + } + mtproto::Bytes sealed; + if (!base64_decode(text.substr(kEncryptedPrefix.size()), sealed)) return false; + try { + raw = session_crypto::decrypt(*passphrase, sealed); + } catch (const std::runtime_error&) { + throw std::runtime_error("session string could not be decrypted: wrong passphrase or damaged text"); + } + } else if (!base64_decode(text, raw)) { + return false; + } + if (raw.size() != kSize || raw[0] != kVersion) return false; out.dc_id = static_cast(static_cast(get_le(raw, 1, 4))); out.time_offset = static_cast(get_le(raw, 5, 8)); out.server_salt = get_le(raw, 13, 8); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c7bff89..5383dda 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp mtproto/session_string_encrypted_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/session_string_encrypted_test.cpp b/tests/mtproto/session_string_encrypted_test.cpp new file mode 100644 index 0000000..b8c08d0 --- /dev/null +++ b/tests/mtproto/session_string_encrypted_test.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include +#include + +#include "cppgram/session_string.h" + +using namespace cppgram; + +namespace { + +void set_pass(const char* v) { + if (v == nullptr) ::unsetenv("CPPGRAM_SESSION_PASSPHRASE"); + else ::setenv("CPPGRAM_SESSION_PASSPHRASE", v, 1); +} + +struct EnvGuard { + ~EnvGuard() { set_pass(nullptr); } +}; + +mtproto::SessionData sample() { + mtproto::SessionData d; + d.dc_id = 3; + d.time_offset = -5; + d.server_salt = 99; + d.auth_key.assign(256, 0x5A); + return d; +} + +} // namespace + +TEST(SessionStringEncrypted, PlainWithoutPassphraseAsBefore) { + EnvGuard guard; + set_pass(nullptr); + const std::string s = encode_session_string(sample()); + EXPECT_NE(s.rfind("cge1:", 0), 0u); + mtproto::SessionData out; + ASSERT_TRUE(decode_session_string(s, out)); + EXPECT_EQ(out.auth_key, sample().auth_key); +} + +TEST(SessionStringEncrypted, EncryptedRoundTripWithPassphrase) { + EnvGuard guard; + set_pass("string-pass-123"); + const std::string s = encode_session_string(sample()); + EXPECT_EQ(s.rfind("cge1:", 0), 0u); + set_pass(nullptr); + const std::string plain = encode_session_string(sample()); + // The plain base64 of the session must not appear inside the encrypted text. + EXPECT_EQ(s.find(plain.substr(4, 40)), std::string::npos); + set_pass("string-pass-123"); + mtproto::SessionData out; + ASSERT_TRUE(decode_session_string(s, out)); + EXPECT_EQ(out.auth_key, sample().auth_key); + EXPECT_EQ(out.dc_id, 3); + EXPECT_EQ(out.time_offset, -5); + EXPECT_EQ(out.server_salt, 99u); +} + +TEST(SessionStringEncrypted, EncryptedStringWithoutPassphraseIsAnError) { + EnvGuard guard; + set_pass("p1"); + const std::string s = encode_session_string(sample()); + set_pass(nullptr); + mtproto::SessionData out; + EXPECT_THROW(decode_session_string(s, out), std::runtime_error); +} + +TEST(SessionStringEncrypted, WrongPassphraseIsAnError) { + EnvGuard guard; + set_pass("p1"); + const std::string s = encode_session_string(sample()); + set_pass("p2"); + mtproto::SessionData out; + EXPECT_THROW(decode_session_string(s, out), std::runtime_error); +} + +TEST(SessionStringEncrypted, TamperedEncryptedStringIsRejected) { + EnvGuard guard; + set_pass("p1"); + std::string s = encode_session_string(sample()); + // Change one character well inside the payload; base64 stays valid, the tag must fail. + s[s.size() / 2] = s[s.size() / 2] == 'A' ? 'B' : 'A'; + mtproto::SessionData out; + EXPECT_ANY_THROW({ + if (!decode_session_string(s, out)) throw std::runtime_error("malformed"); + }); +} From e03e96565506c52b927ac7be3c94b1b4615fcde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:07:44 +0000 Subject: [PATCH 13/25] tools: ThreadSanitizer run script Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- tools/run_tsan.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100755 tools/run_tsan.sh diff --git a/tools/run_tsan.sh b/tools/run_tsan.sh new file mode 100755 index 0000000..5aea117 --- /dev/null +++ b/tools/run_tsan.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Builds the test suite with ThreadSanitizer in build-tsan/ and runs the concurrency tests. +# -O1 -g0 and -j1: the api_impl_*.cpp files are OOM-killed on an 8 GB machine with -j2. Expect 1.5 h or more. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cmake -S "$ROOT" -B "$ROOT/build-tsan" -DCMAKE_BUILD_TYPE=None -DENABLE_TESTS=ON \ + -DCMAKE_CXX_FLAGS="-O1 -g0 -fsanitize=thread -fno-omit-frame-pointer" \ + -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread" \ + -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=thread" +cmake --build "$ROOT/build-tsan" -j1 --target cppgram_mtproto_test +# Concurrency-focused tests first; a full run follows if these pass. +TSAN_OPTIONS="halt_on_error=1:second_deadlock_stack=1" "$ROOT/build-tsan/tests/cppgram_mtproto_test" \ + --gtest_filter='CancelStress*:Cancel*:Socks5*:SessionCrypto*' +TSAN_OPTIONS="halt_on_error=1" "$ROOT/build-tsan/tests/cppgram_mtproto_test" From 57b93bfa378db1cfd00f4fd9082d016b94085c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:08:22 +0000 Subject: [PATCH 14/25] docs: obfuscated proxy plan (design only) Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- guides/OBFUSCATED_PROXY_PLAN.md | 39 +++++++++++++++++++++++++++++++++ guides/README.md | 1 + 2 files changed, 40 insertions(+) create mode 100644 guides/OBFUSCATED_PROXY_PLAN.md diff --git a/guides/OBFUSCATED_PROXY_PLAN.md b/guides/OBFUSCATED_PROXY_PLAN.md new file mode 100644 index 0000000..878081c --- /dev/null +++ b/guides/OBFUSCATED_PROXY_PLAN.md @@ -0,0 +1,39 @@ +# MTProto obfuscated proxy: plan (not implemented) + +Status: design only. No code. The obfuscated transport is not implemented and is not verified against a real proxy. + +## What exists now + +- `mtproto::socks5_connect` returns a connected fd. `Connection::connect_via_socks5` uses it. `ClientOptions::socks5` routes the DC connection through it. Tested against a scripted loopback proxy only. +- The transport layer (`Transport`) sits on top of a connected fd. An obfuscated layer can wrap the fd without changing framing. + +## What the obfuscated transport needs (to be verified against the official spec before coding) + +Reference: https://core.telegram.org/mtproto/mtproto-transports (obfuscated section). Items below are my reading and must be checked: + +1. Client sends a 64-byte random init. Derived keys come from SHA-256 over parts of the init and the proxy secret. Two AES-256-CTR streams: one for sending, one for receiving (the receive key uses the reversed init). +2. Bytes in the init carry the transport tag and DC id. They are encrypted with the send stream. +3. Secret formats to support: + - 16-byte hex secret (plain) + - `dd` prefix (padded intermediate transport) + - `ee` prefix (fake-TLS). This needs a TLS ClientHello imitation and is a separate, larger item. Not in the first step. +4. Everything after the init goes through the CTR streams. Packet framing stays the existing intermediate transport. + +## What I need from you to test + +- Proxy host and port (the proxy you run or rent; do not paste a public list of secrets here) +- Secret type: plain 16-byte hex, `dd...`, or `ee...` +- Whether the test runs from the VPS (recommended) and which DC to connect to +- Test account credentials stay with you. Never paste them in chat. + +## Order of work once the above is known + +1. Implement plain-secret obfuscated init + CTR streams as a transport wrapper, behind the same fd interface. +2. Unit tests: key derivation against known vectors from the spec, round trip of init, wrong secret fails. +3. Live test on the VPS: connect via proxy to DC, do `help.getConfig`, record the result. +4. Only then `dd` and `ee`. + +## Limits + +- Until step 3 passes on a real proxy, the feature is not claimed as working. +- SOCKS5 remains the supported proxy path. diff --git a/guides/README.md b/guides/README.md index 91c54dc..ad719a8 100644 --- a/guides/README.md +++ b/guides/README.md @@ -14,3 +14,4 @@ | [VERSIONING.md](VERSIONING.md) | Semver policy and API compatibility rules | | [MIGRATION.md](MIGRATION.md) | What changed for existing code and data | - [Encrypted session files](SESSION_ENCRYPTION.md): passphrase, backup, recovery, limits. +- [Obfuscated proxy plan](OBFUSCATED_PROXY_PLAN.md): design only, not implemented. From 03073a14abdc9c1863e08e50fcb596dda06edee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:43:40 +0000 Subject: [PATCH 15/25] cancel: fix lost stop wakeup in sleep_cancellable ThreadSanitizer run of CancelStress found one waiter returning normally after a stop request. condition_variable_any waited on an internal mutex, so the stop callback could fire between the waiter's check and its wait. Now a std::condition_variable with a stop callback that takes the waiter's mutex, plus a deadline loop. TSan: 188/188 pass, 0 warnings. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- src/mtproto/cancel.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/mtproto/cancel.cpp b/src/mtproto/cancel.cpp index 9101f5e..c880de8 100644 --- a/src/mtproto/cancel.cpp +++ b/src/mtproto/cancel.cpp @@ -1,5 +1,6 @@ #include "cppgram/mtproto/cancel.h" +#include #include #include @@ -8,11 +9,19 @@ namespace cppgram::mtproto { void sleep_cancellable(std::chrono::milliseconds duration, std::stop_token stop) { if (stop.stop_requested()) throw OperationCancelled(); std::mutex m; - std::condition_variable_any cv; + std::condition_variable cv; + // The callback takes the same mutex as the waiter. A stop request therefore cannot land between the + // waiter's check and its wait, so the notification is never lost. + std::stop_callback wake(stop, [&m, &cv] { + std::lock_guard guard(m); + cv.notify_all(); + }); + const auto deadline = std::chrono::steady_clock::now() + duration; std::unique_lock lock(m); - // The predicate is true only after a stop request, so a timeout returns false. - const bool stopped = cv.wait_for(lock, stop, duration, [&stop] { return stop.stop_requested(); }); - if (stopped) throw OperationCancelled(); + while (!stop.stop_requested()) { + if (cv.wait_until(lock, deadline) == std::cv_status::timeout) break; + } + if (stop.stop_requested()) throw OperationCancelled(); } } // namespace cppgram::mtproto From a602feb549ab3847f97784967f2da2b8996d4939 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:52:19 +0000 Subject: [PATCH 16/25] chore: ignore ThreadSanitizer build directory Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c282f82..3159c33 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ out/ bot_workspace/ *.bak build-san/ +build-tsan/ From 69809f0cf47288da0b0699e78b83271dae1da536 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:52:30 +0000 Subject: [PATCH 17/25] docs: release report with measured results and explicit gaps Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- guides/README.md | 1 + guides/RELEASE_REPORT.md | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 guides/RELEASE_REPORT.md diff --git a/guides/README.md b/guides/README.md index ad719a8..6f8b546 100644 --- a/guides/README.md +++ b/guides/README.md @@ -15,3 +15,4 @@ | [MIGRATION.md](MIGRATION.md) | What changed for existing code and data | - [Encrypted session files](SESSION_ENCRYPTION.md): passphrase, backup, recovery, limits. - [Obfuscated proxy plan](OBFUSCATED_PROXY_PLAN.md): design only, not implemented. +- [Release report](RELEASE_REPORT.md): verified results, incomplete list, verdict. diff --git a/guides/RELEASE_REPORT.md b/guides/RELEASE_REPORT.md new file mode 100644 index 0000000..c0ec6a0 --- /dev/null +++ b/guides/RELEASE_REPORT.md @@ -0,0 +1,58 @@ +# Release report: verification status + +Scope: the cppgram tree at the HEAD named in the final zip. This is a status report, not a production-readiness claim. +Each result below names the environment it was measured in. Nothing here was run on Telegram's live servers. + +## Verdict + +Not production-ready. Live Telegram behaviour (login, reconnect, flood waits, SOCKS5 through a real proxy, encrypted +sessions against a real account) has not been verified. Until those pass on a real account, this library is a +tested component set, not a proven client. + +## Test results (sandbox: Ubuntu 24.04, GCC 13.3, 2 vCPU, 8 GB RAM, OpenSSL 3.0.13) + +| Check | Command | Result | +|---|---|---| +| Release unit + integration suite | `ctest -j2` (Release) | 267/267 pass, 0 compiler warnings | +| ThreadSanitizer, test binary | `cppgram_mtproto_test` built `-O0 -fsanitize=thread`, -j1 | 188/188 pass, 0 warnings (after fix in 03073a1) | +| ThreadSanitizer before fix | same | 1 failure: a stop request could be missed by a waiter; fixed | +| libFuzzer + ASan, TL reader | 500 s | 11.7M runs, 0 crashes | +| libFuzzer + ASan, HTML parser | 500 s | 7.2M runs, 0 crashes | +| libFuzzer + ASan, session string | 100 s | 66.9M runs, 0 crashes | +| ASan + UBSan, test binary | see `san_build.log` / run log | filled in below if completed | + +Fuzzing used ASan only, not UBSan, and ran for minutes rather than hours. + +## Implemented (with tests) + +- MTProto handshake with DH validation, message keys, AES-256-IGE, replay defence for server messages (F16) +- Abridged and intermediate transports, framing and malformed-frame tests +- Auth state machine with RPC error mapping (13 tests) +- Retry policy, bounded reconnect budget, cancellable sleep (3 + 2 stress tests) +- Session storage: CGS2 file with CRC, SQLite store with integrity check and schema version +- Encrypted sessions: file, SQLite key column, session string. scrypt + AES-256-GCM, passphrase from env (`CPPGRAM_SESSION_PASSPHRASE`). Documented in `guides/SESSION_ENCRYPTION.md` +- SOCKS5 CONNECT with username/password auth, IPv4, IPv6, domain targets, timeouts (13 tests, loopback proxy) +- SOCKS5 wired into the main DC connection (`ClientOptions::socks5`), tested against a loopback proxy only +- Update gate: account pts, per-channel pts (in memory), gap recovery via getDifference / getChannelDifference +- Typed events: connection state, auth state, RPC errors. Handler exceptions are isolated +- Uploads: parts streamed from disk, MD5 computed in a streaming pass; plan limits enforced +- Downloads: written to `.part`, resumed from an aligned offset, atomic rename on success + +## Explicitly incomplete + +- Live verification: login, reconnect, flood waits, SOCKS5 through a real proxy, encrypted session against a real account +- MTProto obfuscated proxy: design only (`guides/OBFUSCATED_PROXY_PLAN.md`). No code +- Per-channel pts is in memory only. After restart the first update per channel is trusted again +- Download resume trusts the `.part` file by size. Telegram gives no hash for these files +- `export_session_string()` and SQLite store are encrypted only when the passphrase env var is set +- Passphrase rotation is not implemented +- CDN downloads connect directly, not through the proxy +- Typed events cover connection, auth state, and RPC errors only. Message-level typed events are the existing handler API +- Kurigram-level feature parity is out of scope for this library release + +## Known risks + +- Never tested against a live Telegram DC from this environment +- scrypt cost (N=2^15) makes each encrypted load or save take about 100 ms +- A stolen authorized session gives account access. Encryption at rest does not protect a running process +- CI results for the current HEAD were observed by the maintainer, not reproduced here From 9772893609493402c92983ee7d72c49b5d3bfa45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:23:13 +0000 Subject: [PATCH 18/25] docs: record ASan and UBSan full-suite result Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- guides/RELEASE_REPORT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/RELEASE_REPORT.md b/guides/RELEASE_REPORT.md index c0ec6a0..22da691 100644 --- a/guides/RELEASE_REPORT.md +++ b/guides/RELEASE_REPORT.md @@ -19,7 +19,7 @@ tested component set, not a proven client. | libFuzzer + ASan, TL reader | 500 s | 11.7M runs, 0 crashes | | libFuzzer + ASan, HTML parser | 500 s | 7.2M runs, 0 crashes | | libFuzzer + ASan, session string | 100 s | 66.9M runs, 0 crashes | -| ASan + UBSan, test binary | see `san_build.log` / run log | filled in below if completed | +| ASan + UBSan, full ctest | `ctest` in build-san, `-O0 -g0`, -j1 | 267/267 pass, 0 ASan or UBSan reports | Fuzzing used ASan only, not UBSan, and ran for minutes rather than hours. From c8fae999ab68ff4effcbd38edaa521f688a8a278 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:54:14 +0000 Subject: [PATCH 19/25] mtproto: proxy secret parser (format only), 9 offline tests Parses plain (32 hex), dd (34 chars) and ee (fake-TLS domain) secrets with strict hex and length checks. Errors never echo the secret. The obfuscated handshake itself is not implemented: the official transport spec could not be fetched in this environment, so its byte layout and key derivation are not claimed as verified. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- CMakeLists.txt | 1 + include/cppgram/mtproto/proxy_secret.h | 26 ++++++++ src/mtproto/proxy_secret.cpp | 75 +++++++++++++++++++++ tests/CMakeLists.txt | 2 +- tests/mtproto/proxy_secret_test.cpp | 91 ++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 include/cppgram/mtproto/proxy_secret.h create mode 100644 src/mtproto/proxy_secret.cpp create mode 100644 tests/mtproto/proxy_secret_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index edb2c6d..46f6eee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ add_library(cppgram src/transfer.cpp src/mtproto/cancel.cpp src/mtproto/socks5.cpp + src/mtproto/proxy_secret.cpp src/channel_pts.cpp src/client_extra.cpp src/client_more.cpp diff --git a/include/cppgram/mtproto/proxy_secret.h b/include/cppgram/mtproto/proxy_secret.h new file mode 100644 index 0000000..6ab51a6 --- /dev/null +++ b/include/cppgram/mtproto/proxy_secret.h @@ -0,0 +1,26 @@ +// cppgram - MTProto proxy secret parsing (format only, no network or crypto). +// Layouts (hex text): 32 chars = 16-byte secret; "dd" + 32 chars = padded-intermediate marker + secret; +// "ee" + 32 chars + hex-encoded domain = fake-TLS marker + secret + SNI domain. +// These layouts come from third-party documentation and client sources. Check them against the official +// Telegram proxy documentation before relying on them. Parsing errors never include the secret text. +#pragma once + +#include +#include +#include + +namespace cppgram::mtproto { + +enum class ProxySecretKind { Plain, PaddedIntermediate, FakeTls }; + +struct ProxySecret { + ProxySecretKind kind = ProxySecretKind::Plain; + std::array key{}; + std::string fake_tls_domain; // only for FakeTls, ASCII, 1..253 bytes +}; + +// Parses a secret given as hex text. Accepts upper or lower case. Throws std::invalid_argument on any +// malformed input (wrong length, non-hex, empty or invalid domain). +ProxySecret parse_proxy_secret(const std::string& hex); + +} // namespace cppgram::mtproto diff --git a/src/mtproto/proxy_secret.cpp b/src/mtproto/proxy_secret.cpp new file mode 100644 index 0000000..95851cd --- /dev/null +++ b/src/mtproto/proxy_secret.cpp @@ -0,0 +1,75 @@ +#include "cppgram/mtproto/proxy_secret.h" + +#include +#include +#include + +namespace cppgram::mtproto { +namespace { + +int hex_value(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// Decodes hex into bytes. Returns false on odd length or a non-hex character. Never includes the input in errors. +bool decode_hex(const std::string& text, std::string& out) { + if (text.size() % 2 != 0) return false; + out.clear(); + out.reserve(text.size() / 2); + for (std::size_t i = 0; i < text.size(); i += 2) { + const int hi = hex_value(text[i]); + const int lo = hex_value(text[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back(static_cast((hi << 4) | lo)); + } + return true; +} + +} // namespace + +ProxySecret parse_proxy_secret(const std::string& hex) { + ProxySecret secret; + std::string bytes; + if (hex.size() == 32) { + secret.kind = ProxySecretKind::Plain; + if (!decode_hex(hex, bytes)) throw std::invalid_argument("proxy secret is not valid hex"); + std::copy(bytes.begin(), bytes.end(), secret.key.begin()); + return secret; + } + if (hex.size() < 34) throw std::invalid_argument("proxy secret has an unsupported length"); + + const std::string tag = hex.substr(0, 2); + const std::string body = hex.substr(2, 32); + if (tag == "dd") { + if (hex.size() != 34) throw std::invalid_argument("padded proxy secret has the wrong length"); + secret.kind = ProxySecretKind::PaddedIntermediate; + } else if (tag == "ee") { + if (hex.size() <= 34) throw std::invalid_argument("fake-TLS proxy secret must include a domain"); + secret.kind = ProxySecretKind::FakeTls; + } else { + throw std::invalid_argument("proxy secret has an unknown prefix"); + } + if (!decode_hex(body, bytes)) throw std::invalid_argument("proxy secret is not valid hex"); + std::copy(bytes.begin(), bytes.end(), secret.key.begin()); + + if (secret.kind == ProxySecretKind::FakeTls) { + std::string domain_bytes; + if (!decode_hex(hex.substr(34), domain_bytes)) { + throw std::invalid_argument("fake-TLS domain is not valid hex"); + } + if (domain_bytes.empty() || domain_bytes.size() > 253) { + throw std::invalid_argument("fake-TLS domain must be 1..253 bytes"); + } + for (char c : domain_bytes) { + const auto u = static_cast(c); + if (u < 0x21 || u > 0x7e) throw std::invalid_argument("fake-TLS domain has non-printable characters"); + } + secret.fake_tls_domain = std::move(domain_bytes); + } + return secret; +} + +} // namespace cppgram::mtproto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5383dda..f815f02 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp mtproto/session_string_encrypted_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/proxy_secret_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp mtproto/session_string_encrypted_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/proxy_secret_test.cpp b/tests/mtproto/proxy_secret_test.cpp new file mode 100644 index 0000000..c416a47 --- /dev/null +++ b/tests/mtproto/proxy_secret_test.cpp @@ -0,0 +1,91 @@ +#include + +#include +#include + +#include "cppgram/mtproto/proxy_secret.h" + +using namespace cppgram::mtproto; + +namespace { +const std::string kKey = "0123456789abcdef0123456789ABCDEF"; // 32 hex chars = 16 bytes +} + +TEST(ProxySecret, PlainSecretParsesBothCases) { + const auto s = parse_proxy_secret(kKey); + EXPECT_EQ(s.kind, ProxySecretKind::Plain); + EXPECT_EQ(s.key[0], 0x01); + EXPECT_EQ(s.key[1], 0x23); + EXPECT_EQ(s.key[15], 0xEF); + EXPECT_TRUE(s.fake_tls_domain.empty()); +} + +TEST(ProxySecret, PaddedIntermediateSecret) { + const auto s = parse_proxy_secret("dd" + kKey); + EXPECT_EQ(s.kind, ProxySecretKind::PaddedIntermediate); + EXPECT_EQ(s.key[0], 0x01); + EXPECT_EQ(s.key[15], 0xEF); +} + +TEST(ProxySecret, FakeTlsSecretCarriesDomain) { + // "a.b" as hex: 61 2e 62 + const auto s = parse_proxy_secret("ee" + kKey + "612e62"); + EXPECT_EQ(s.kind, ProxySecretKind::FakeTls); + EXPECT_EQ(s.key[15], 0xEF); + EXPECT_EQ(s.fake_tls_domain, "a.b"); +} + +TEST(ProxySecret, RejectsWrongLengths) { + EXPECT_THROW(parse_proxy_secret(""), std::invalid_argument); + EXPECT_THROW(parse_proxy_secret(kKey.substr(0, 31)), std::invalid_argument); + EXPECT_THROW(parse_proxy_secret(kKey + "0"), std::invalid_argument); // 33 chars, no prefix + EXPECT_THROW(parse_proxy_secret("dd" + kKey + "00"), std::invalid_argument); // dd with extra bytes + EXPECT_THROW(parse_proxy_secret("dd" + kKey.substr(0, 31)), std::invalid_argument); // 33 chars + // "dd" plus 30 chars is 32 chars total, which is a valid plain secret, so it must parse as Plain. + EXPECT_EQ(parse_proxy_secret("dd" + kKey.substr(0, 30)).kind, ProxySecretKind::Plain); +} + +TEST(ProxySecret, RejectsNonHex) { + std::string bad = kKey; + bad[5] = 'g'; + EXPECT_THROW(parse_proxy_secret(bad), std::invalid_argument); + bad = kKey; + bad[0] = ' '; + EXPECT_THROW(parse_proxy_secret(bad), std::invalid_argument); +} + +TEST(ProxySecret, RejectsUnknownPrefix) { + EXPECT_THROW(parse_proxy_secret("ff" + kKey), std::invalid_argument); + EXPECT_THROW(parse_proxy_secret("00" + kKey), std::invalid_argument); +} + +TEST(ProxySecret, FakeTlsNeedsDomain) { + EXPECT_THROW(parse_proxy_secret("ee" + kKey), std::invalid_argument); // no domain bytes + EXPECT_THROW(parse_proxy_secret("ee" + kKey + "6"), std::invalid_argument); // odd domain hex + EXPECT_THROW(parse_proxy_secret("ee" + kKey + "zz"), std::invalid_argument); +} + +TEST(ProxySecret, FakeTlsDomainRules) { + // Control character 0x0a in the domain. + EXPECT_THROW(parse_proxy_secret("ee" + kKey + "610a62"), std::invalid_argument); + // Space (0x20) is not allowed. + EXPECT_THROW(parse_proxy_secret("ee" + kKey + "612062"), std::invalid_argument); + // 254 bytes is over the limit, 253 is allowed. + const std::string too_long(254 * 2, '6'); + EXPECT_THROW(parse_proxy_secret("ee" + kKey + too_long), std::invalid_argument); + const std::string max_len(253 * 2, '6'); + EXPECT_EQ(parse_proxy_secret("ee" + kKey + max_len).fake_tls_domain.size(), 253u); +} + +TEST(ProxySecret, ErrorMessageNeverEchoesSecret) { + std::string bad = kKey; + bad[7] = 'x'; + try { + parse_proxy_secret(bad); + FAIL() << "expected rejection"; + } catch (const std::invalid_argument& e) { + const std::string msg = e.what(); + EXPECT_EQ(msg.find(kKey), std::string::npos); + EXPECT_EQ(msg.find(bad), std::string::npos); + } +} From 791601b1381cfc2c85112e27fa66086d324e3cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:58:20 +0000 Subject: [PATCH 20/25] docs: long-fuzz results and proxy status in release report Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- guides/RELEASE_REPORT.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/guides/RELEASE_REPORT.md b/guides/RELEASE_REPORT.md index 22da691..fded09c 100644 --- a/guides/RELEASE_REPORT.md +++ b/guides/RELEASE_REPORT.md @@ -23,6 +23,20 @@ tested component set, not a proven client. Fuzzing used ASan only, not UBSan, and ran for minutes rather than hours. +## Long-duration fuzzing (ASan, libFuzzer, 600 s per target) + +Measured wall time per target: 602 s. The three targets ran at the same time on 2 vCPU (not one after another). + +| Target | Duration | Runs | Crashes | Sanitizer findings | +|---|---|---|---|---| +| fuzz_tl_reader | 601 s | 9,188,031 | 0 | 0 | +| fuzz_html_parser | 601 s | 6,378,889 | 0 | 0 | +| fuzz_session_string | 601 s | 264,549,266 | 0 | 0 | + +Earlier 500 s / 500 s / 100 s runs are smoke-level and are not counted as long runs. +Coverage numbers are in the logs (`lf/*.log` in the session workspace). No crash artifacts were produced. +Not run: fuzz_session_string was not run under UBSan; the transport-frame fuzz target does not exist yet. + ## Implemented (with tests) - MTProto handshake with DH validation, message keys, AES-256-IGE, replay defence for server messages (F16) @@ -31,6 +45,7 @@ Fuzzing used ASan only, not UBSan, and ran for minutes rather than hours. - Retry policy, bounded reconnect budget, cancellable sleep (3 + 2 stress tests) - Session storage: CGS2 file with CRC, SQLite store with integrity check and schema version - Encrypted sessions: file, SQLite key column, session string. scrypt + AES-256-GCM, passphrase from env (`CPPGRAM_SESSION_PASSPHRASE`). Documented in `guides/SESSION_ENCRYPTION.md` +- MTProto proxy secret parser (plain, dd, ee formats), 9 offline tests. Format only, see limitations - SOCKS5 CONNECT with username/password auth, IPv4, IPv6, domain targets, timeouts (13 tests, loopback proxy) - SOCKS5 wired into the main DC connection (`ClientOptions::socks5`), tested against a loopback proxy only - Update gate: account pts, per-channel pts (in memory), gap recovery via getDifference / getChannelDifference @@ -41,7 +56,8 @@ Fuzzing used ASan only, not UBSan, and ran for minutes rather than hours. ## Explicitly incomplete - Live verification: login, reconnect, flood waits, SOCKS5 through a real proxy, encrypted session against a real account -- MTProto obfuscated proxy: design only (`guides/OBFUSCATED_PROXY_PLAN.md`). No code +- MTProto obfuscated proxy transport (obfuscated2 handshake): NOT implemented. The official transport spec could not be fetched here, so byte layout and key derivation are unverified. Design in `guides/OBFUSCATED_PROXY_PLAN.md` +- Proxy secret layouts (32/34/36+ hex) come from third-party docs; check against the official proxy documentation - Per-channel pts is in memory only. After restart the first update per channel is trusted again - Download resume trusts the `.part` file by size. Telegram gives no hash for these files - `export_session_string()` and SQLite store are encrypted only when the passphrase env var is set From ddd3f0fe5bacfc1028f7bb1cc7c17cee6df5d64a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:22:02 +0000 Subject: [PATCH 21/25] fix(bot): include before Boost.Asio in webhook server headers HttpServer.h included after the Boost.Asio and Beast headers. Boost 1.74 awaitable.hpp uses std::exchange without including itself, so on GCC 12 + Boost 1.74 + C++20 it failed with "'exchange' is not a member of 'std'". Standard includes now precede all Boost includes in HttpServer.h, and both webhook .cpp files include first. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- include/cppgram/bot/HttpServer.h | 12 +++++++----- src/TgWebhookLocalServer.cpp | 2 ++ src/TgWebhookTcpServer.cpp | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/include/cppgram/bot/HttpServer.h b/include/cppgram/bot/HttpServer.h index ecfbb47..d58feff 100644 --- a/include/cppgram/bot/HttpServer.h +++ b/include/cppgram/bot/HttpServer.h @@ -1,10 +1,7 @@ #pragma once -#include -#include -#include -#include - +// Standard headers first: Boost.Asio 1.74 (e.g. awaitable.hpp) uses std::exchange +// without including itself, so must be seen before any Boost include. #include #include #include @@ -17,6 +14,11 @@ #include #include +#include +#include +#include +#include + namespace cppgram::bot { /** diff --git a/src/TgWebhookLocalServer.cpp b/src/TgWebhookLocalServer.cpp index 160aee5..b983420 100644 --- a/src/TgWebhookLocalServer.cpp +++ b/src/TgWebhookLocalServer.cpp @@ -1,3 +1,5 @@ +#include + #include "cppgram/bot/TgWebhookLocalServer.h" #ifdef BOOST_ASIO_HAS_LOCAL_SOCKETS diff --git a/src/TgWebhookTcpServer.cpp b/src/TgWebhookTcpServer.cpp index 798b6fa..c93aa19 100644 --- a/src/TgWebhookTcpServer.cpp +++ b/src/TgWebhookTcpServer.cpp @@ -1,3 +1,5 @@ +#include + #include "cppgram/bot/TgWebhookTcpServer.h" namespace cppgram::bot { From 755560a4837469dbc49b971642378390d54e66b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:36:25 +0000 Subject: [PATCH 22/25] mtproto: obfuscated MTProto proxy transport, connect/send timeouts, offline network tests - Add obfuscated2 codec (obfuscated.h/.cpp): 64-byte handshake with reserved-prefix rejection, tag and DC id encryption, SHA256(key || secret) key mixing, AES-256-CTR per direction, intermediate and padded-intermediate framing, and a server-side decode used by tests. - Connection: connect_obfuscated() for MTProto proxies; non-blocking TCP connect bounded by a timeout (direct and proxy paths); SO_SNDTIMEO applied with the receive timeout. - ClientOptions::mtproto_proxy (host, port, hex secret, timeout). Fake-TLS (ee) secrets are rejected. socks5 and mtproto_proxy together are rejected. Direct and SOCKS5 paths unchanged. - Tests: obfuscated_test (layout, key mixing, tamper, framing, CTR), connection_net_test (refused connect, reconnect after drop, receive timeout, accept-queue connect timeout, scripted obfuscated proxy plain and padded, proxy drop), and a flood-wait cancellation test. The obfuscated expectations come from the layout rules, not from captured proxy traffic. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- CMakeLists.txt | 1 + include/cppgram/client.h | 14 ++ include/cppgram/mtproto/connection.h | 22 +- include/cppgram/mtproto/obfuscated.h | 105 +++++++++ src/client.cpp | 30 ++- src/mtproto/connection.cpp | 145 ++++++++++-- src/mtproto/obfuscated.cpp | 190 ++++++++++++++++ tests/CMakeLists.txt | 2 +- tests/mtproto/connection_net_test.cpp | 311 ++++++++++++++++++++++++++ tests/mtproto/obfuscated_test.cpp | 202 +++++++++++++++++ tests/mtproto/session_test.cpp | 23 ++ 11 files changed, 1022 insertions(+), 23 deletions(-) create mode 100644 include/cppgram/mtproto/obfuscated.h create mode 100644 src/mtproto/obfuscated.cpp create mode 100644 tests/mtproto/connection_net_test.cpp create mode 100644 tests/mtproto/obfuscated_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 46f6eee..4619ee0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,7 @@ add_library(cppgram src/mtproto/cancel.cpp src/mtproto/socks5.cpp src/mtproto/proxy_secret.cpp + src/mtproto/obfuscated.cpp src/channel_pts.cpp src/client_extra.cpp src/client_more.cpp diff --git a/include/cppgram/client.h b/include/cppgram/client.h index 0414c6f..ab64347 100644 --- a/include/cppgram/client.h +++ b/include/cppgram/client.h @@ -19,6 +19,7 @@ #include "cppgram/mtproto/retry.h" #include "cppgram/mtproto/socks5.h" #include "cppgram/mtproto/connection.h" +#include "cppgram/mtproto/proxy_secret.h" #include "cppgram/mtproto/schema/api.h" #include "cppgram/mtproto/session.h" #include "cppgram/parser.h" @@ -26,6 +27,16 @@ namespace cppgram { +// MTProto proxy (obfuscated transport) for the main DC connection. The secret is hex text: 32 characters for +// a plain secret, or "dd" followed by 32 characters for a padded-intermediate secret. Fake-TLS ("ee") secrets +// are rejected. The secret is never logged. +struct MtprotoProxyOptions { + std::string host; + std::uint16_t port = 443; + std::string secret; + int connect_timeout_ms = 10000; +}; + struct ClientOptions { std::int32_t api_id = 0; std::string api_hash; @@ -39,6 +50,9 @@ struct ClientOptions { std::string session_string; // SOCKS5 proxy for the main DC connection. Unset: direct TCP. CDN downloads still connect directly. std::optional socks5; + // MTProto proxy (obfuscated transport) for the main DC connection. Unset: direct TCP. Cannot be combined + // with socks5 (connect() throws std::invalid_argument if both are set). + std::optional mtproto_proxy; std::string device_model = "cppgram"; std::string system_version = "linux"; std::string app_version = "0.1.0"; diff --git a/include/cppgram/mtproto/connection.h b/include/cppgram/mtproto/connection.h index 7f6846a..794753d 100644 --- a/include/cppgram/mtproto/connection.h +++ b/include/cppgram/mtproto/connection.h @@ -1,9 +1,14 @@ // cppgram - blocking TCP connection that moves MTProto packets through a transport codec (POSIX sockets). #pragma once +#include #include +#include +#include #include +#include "cppgram/mtproto/obfuscated.h" +#include "cppgram/mtproto/proxy_secret.h" #include "cppgram/mtproto/socks5.h" #include "cppgram/mtproto/transport.h" @@ -19,7 +24,8 @@ class ConnectionTimeout : public ConnectionError { using ConnectionError::ConnectionError; }; -// A SOCKS5 proxy refused or failed the tunnel. transient() is false for rejected credentials and refused targets. +// A SOCKS5 or MTProto proxy refused or failed the tunnel. transient() is false for rejected credentials and +// refused targets. class ProxyError : public ConnectionError { public: ProxyError(const std::string& message, bool transient) : ConnectionError(message), transient_(transient) {} @@ -44,10 +50,19 @@ class Connection : public PacketChannel { Connection(const Connection&) = delete; Connection& operator=(const Connection&) = delete; + // Direct TCP connection. Failures throw ConnectionError; a connect that does not finish within + // set_connect_timeout() throws ConnectionTimeout. void connect(const std::string& ipv4, std::uint16_t port); // Connects to ipv4:port through a SOCKS5 proxy. Failures throw ProxyError. void connect_via_socks5(const Socks5Options& proxy, const std::string& ipv4, std::uint16_t port); - // Receive timeout in milliseconds (0 = block forever). Expiry throws ConnectionTimeout. + // Connects to an MTProto proxy (obfuscated transport) and sends the 64-byte handshake for dc_id. + // The secret must be a plain or padded-intermediate secret; fake-TLS secrets throw std::invalid_argument. + // Connect failures and timeouts throw ProxyError with transient() == true. + void connect_obfuscated(const std::string& proxy_host, std::uint16_t proxy_port, const ProxySecret& secret, + std::int16_t dc_id, std::chrono::milliseconds timeout); + // Timeout for establishing a TCP connection, in milliseconds (default 10000). + void set_connect_timeout(int milliseconds) noexcept { connect_timeout_ms_ = milliseconds; } + // Receive and send timeout in milliseconds (0 = block forever). Expiry throws ConnectionTimeout. void set_receive_timeout(int milliseconds); void close() noexcept; bool is_open() const noexcept { return fd_ >= 0; } @@ -61,6 +76,9 @@ class Connection : public PacketChannel { Transport transport_; int fd_ = -1; bool init_sent_ = false; + int connect_timeout_ms_ = 10000; + int io_timeout_ms_ = 0; + std::optional obfuscated_; }; } // namespace cppgram::mtproto diff --git a/include/cppgram/mtproto/obfuscated.h b/include/cppgram/mtproto/obfuscated.h new file mode 100644 index 0000000..4dbf134 --- /dev/null +++ b/include/cppgram/mtproto/obfuscated.h @@ -0,0 +1,105 @@ +// cppgram - MTProto obfuscated transport ("obfuscated2") used to talk to MTProto proxies. +// +// Client handshake (64 bytes, sent first, in the clear except the last 8 bytes which are encrypted): +// bytes 0..56 random (rejected if they match a reserved prefix, see obfuscated_init_valid) +// bytes 56..60 protocol tag: 0xeeeeeeee (intermediate) or 0xdddddddd (padded intermediate) +// bytes 60..62 DC id, int16 little-endian +// bytes 62..64 random +// Keys: TX key = SHA256(init[8..40] || secret), TX iv = init[40..56]. RX uses the reversed init the same way. +// The TX keystream is advanced over the 64-byte handshake, so the first encrypted data byte uses offset 64. +// The 16-byte secret is the proxy secret with any "dd" prefix removed. Fake-TLS ("ee") secrets are not supported. +// +// Implemented from the public MTProto transport description. Live compatibility with a real proxy is NOT +// verified by the offline tests in this repository. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/tl.h" + +namespace cppgram::mtproto { + +// Stateful AES-256-CTR keystream. One instance per direction. Move-only. +class CtrStream { +public: + // key must be 32 bytes, iv 16 bytes. Throws std::invalid_argument otherwise. + CtrStream(const Bytes& key, const Bytes& iv); + ~CtrStream(); + CtrStream(CtrStream&&) noexcept; + CtrStream& operator=(CtrStream&&) noexcept; + CtrStream(const CtrStream&) = delete; + CtrStream& operator=(const CtrStream&) = delete; + + // Encrypts or decrypts `data` (same operation for CTR) and advances the keystream. + Bytes apply(const Bytes& data); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +enum class ObfuscatedFraming { + Intermediate, // tag 0xeeeeeeee, frames: u32 length + payload + PaddedIntermediate, // tag 0xdddddddd, frames: u32 length (payload + padding) + payload + 0..15 random bytes +}; + +constexpr std::size_t kObfuscatedInitSize = 64; + +// True when a 64-byte candidate init avoids the reserved prefixes (0xef first byte, HEAD/POST/GET, the +// intermediate and padded tags, a TLS record header prefix, and four zero bytes at offset 4). +bool obfuscated_init_valid(const Bytes& init); + +// Client side of the obfuscated transport. Produces the handshake to send first, then encrypts outgoing +// bytes and decrypts incoming bytes with the two directional keystreams. +class ObfuscatedClient { +public: + // Creates a fresh handshake. `init_override` is for tests only: it must be 64 bytes, pass + // obfuscated_init_valid, and is used instead of random bytes (tag and dc are still written into it). + // Throws std::invalid_argument for a bad override. + static ObfuscatedClient create(ObfuscatedFraming framing, std::int16_t dc_id, + const std::array& secret, + const std::optional& init_override = std::nullopt); + + ObfuscatedClient(ObfuscatedClient&&) noexcept = default; + ObfuscatedClient& operator=(ObfuscatedClient&&) noexcept = default; + + // The 64 bytes to write to the socket before any frame. + const Bytes& handshake() const noexcept { return handshake_; } + ObfuscatedFraming framing() const noexcept { return framing_; } + + // Wraps one MTProto payload in the framing (padding is added for PaddedIntermediate), without encryption. + Bytes frame(const Bytes& payload) const; + // Encrypts outgoing bytes (handshake excluded; the handshake is already encrypted in its tail). + Bytes encrypt(const Bytes& data); + // Decrypts incoming bytes from the proxy. + Bytes decrypt(const Bytes& data); + +private: + ObfuscatedClient(ObfuscatedFraming framing, Bytes handshake, CtrStream tx, CtrStream rx) + : framing_(framing), handshake_(std::move(handshake)), tx_(std::move(tx)), rx_(std::move(rx)) {} + + ObfuscatedFraming framing_; + Bytes handshake_; + CtrStream tx_; + CtrStream rx_; +}; + +// Server view of a client handshake. Used by tests to check that a handshake decodes to the values it encodes, +// and by anything that needs to play the proxy side offline. `to_client` encrypts for the client and `from_client` +// decrypts what the client sent. +struct ObfuscatedServerView { + ObfuscatedFraming framing; + std::int16_t dc_id; + CtrStream from_client; + CtrStream to_client; +}; + +// Throws std::invalid_argument when the handshake is not 64 bytes or its tag is unknown. +ObfuscatedServerView parse_obfuscated_handshake(const Bytes& handshake, const std::array& secret); + +} // namespace cppgram::mtproto diff --git a/src/client.cpp b/src/client.cpp index bdd41e1..5a48f08 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -148,15 +148,39 @@ void Client::connect() { disconnect(); set_auth_state(AuthState::Connecting); const bool debug = options_.debug; + if (options_.socks5 && options_.mtproto_proxy) { + set_auth_state(AuthState::Disconnected); + throw std::invalid_argument("ClientOptions: socks5 and mtproto_proxy cannot be used together"); + } + // Parse the proxy secret once, before any network activity. A malformed or unsupported secret is final. + std::optional proxy_secret; + if (options_.mtproto_proxy) { + proxy_secret = mtproto::parse_proxy_secret(options_.mtproto_proxy->secret); + if (proxy_secret->kind == mtproto::ProxySecretKind::FakeTls) { + set_auth_state(AuthState::Disconnected); + throw std::invalid_argument("fake-TLS (ee) proxy secrets are not supported"); + } + } connection_ = std::make_unique(mtproto::TransportKind::Intermediate); - debug_log(debug, "tcp: connecting to " + options_.dc_ip + ":" + std::to_string(options_.dc_port) + - " (dc " + std::to_string(options_.dc_id) + ")"); + connection_->set_connect_timeout(options_.connect_timeout_ms); + if (options_.mtproto_proxy) { + debug_log(debug, "tcp: connecting to MTProto proxy (dc " + std::to_string(options_.dc_id) + ")"); + } else { + debug_log(debug, "tcp: connecting to " + options_.dc_ip + ":" + std::to_string(options_.dc_port) + + " (dc " + std::to_string(options_.dc_id) + ")"); + } // Connect with bounded retries. A refused or timed-out TCP connect is transient; the same connection // object cannot be reused after a failed connect, so each attempt gets a fresh Connection. mtproto::validate_retry_policy(options_.retry); for (int attempt = 1;; ++attempt) { try { - if (options_.socks5) { + if (options_.mtproto_proxy) { + const auto& proxy = *options_.mtproto_proxy; + connection_->set_connect_timeout(proxy.connect_timeout_ms); + connection_->connect_obfuscated(proxy.host, proxy.port, *proxy_secret, + static_cast(options_.dc_id), + std::chrono::milliseconds(proxy.connect_timeout_ms)); + } else if (options_.socks5) { connection_->connect_via_socks5(*options_.socks5, options_.dc_ip, options_.dc_port); } else { connection_->connect(options_.dc_ip, options_.dc_port); diff --git a/src/mtproto/connection.cpp b/src/mtproto/connection.cpp index dd2c6c6..5d55b3e 100644 --- a/src/mtproto/connection.cpp +++ b/src/mtproto/connection.cpp @@ -1,16 +1,97 @@ #include "cppgram/mtproto/connection.h" #include +#include +#include #include #include +#include #include +#include #include #include -#include #include +#include +#include namespace cppgram::mtproto { +namespace { + +// Opens a blocking TCP socket to host:port. The connect itself runs non-blocking and is bounded by `timeout` +// per candidate address. Throws ConnectionError, or ConnectionTimeout when every candidate timed out. +int tcp_connect(const std::string& host, std::uint16_t port, std::chrono::milliseconds timeout) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* found = nullptr; + const std::string port_text = std::to_string(port); + const int rc = ::getaddrinfo(host.c_str(), port_text.c_str(), &hints, &found); + if (rc != 0) { + throw ConnectionError("resolve " + host + ": " + ::gai_strerror(rc)); + } + std::unique_ptr guard(found, ::freeaddrinfo); + + std::string last_error = "no usable address"; + bool timed_out = false; + const int timeout_ms = static_cast(timeout.count() <= 0 ? 10000 : timeout.count()); + for (addrinfo* ai = found; ai != nullptr; ai = ai->ai_next) { + const int fd = ::socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol); + if (fd < 0) { + last_error = std::strerror(errno); + continue; + } + const int flags = ::fcntl(fd, F_GETFL, 0); + if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { + last_error = std::strerror(errno); + ::close(fd); + continue; + } + const int r = ::connect(fd, ai->ai_addr, ai->ai_addrlen); + if (r != 0 && errno != EINPROGRESS) { + last_error = std::strerror(errno); + ::close(fd); + continue; + } + if (r != 0) { + pollfd waiter{fd, POLLOUT, 0}; + int pr = 0; + do { + pr = ::poll(&waiter, 1, timeout_ms); + } while (pr < 0 && errno == EINTR); + if (pr == 0) { + timed_out = true; + last_error = "timed out"; + ::close(fd); + continue; + } + if (pr < 0) { + last_error = std::strerror(errno); + ::close(fd); + continue; + } + int so_error = 0; + socklen_t len = sizeof(so_error); + if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &len) != 0 || so_error != 0) { + last_error = std::strerror(so_error != 0 ? so_error : errno); + ::close(fd); + continue; + } + } + if (::fcntl(fd, F_SETFL, flags) != 0) { + last_error = std::strerror(errno); + ::close(fd); + continue; + } + return fd; + } + if (timed_out) { + throw ConnectionTimeout("connect " + host + ":" + port_text + ": timed out"); + } + throw ConnectionError("connect " + host + ":" + port_text + ": " + last_error); +} + +} // namespace Connection::Connection(TransportKind kind) : transport_(kind) {} @@ -20,22 +101,11 @@ Connection::~Connection() { void Connection::connect(const std::string& ipv4, std::uint16_t port) { close(); - fd_ = ::socket(AF_INET, SOCK_STREAM, 0); - if (fd_ < 0) { - throw ConnectionError(std::string("socket: ") + std::strerror(errno)); - } - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - if (inet_pton(AF_INET, ipv4.c_str(), &addr.sin_addr) != 1) { - close(); + sockaddr_in literal{}; + if (inet_pton(AF_INET, ipv4.c_str(), &literal.sin_addr) != 1) { throw ConnectionError("invalid IPv4 address: " + ipv4); } - if (::connect(fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) { - const std::string err = std::strerror(errno); - close(); - throw ConnectionError("connect " + ipv4 + ": " + err); - } + fd_ = tcp_connect(ipv4, port, std::chrono::milliseconds(connect_timeout_ms_)); int one = 1; ::setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); init_sent_ = false; @@ -59,11 +129,41 @@ void Connection::connect_via_socks5(const Socks5Options& proxy, const std::strin transport_ = Transport(transport_.kind()); } +void Connection::connect_obfuscated(const std::string& proxy_host, std::uint16_t proxy_port, + const ProxySecret& secret, std::int16_t dc_id, + std::chrono::milliseconds timeout) { + if (secret.kind == ProxySecretKind::FakeTls) { + throw std::invalid_argument("fake-TLS (ee) proxy secrets are not supported"); + } + close(); + try { + fd_ = tcp_connect(proxy_host, proxy_port, timeout); + } catch (const ConnectionError& e) { + throw ProxyError(e.what(), true); + } + int one = 1; + ::setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + + const ObfuscatedFraming framing = secret.kind == ProxySecretKind::PaddedIntermediate + ? ObfuscatedFraming::PaddedIntermediate + : ObfuscatedFraming::Intermediate; + obfuscated_.emplace(ObfuscatedClient::create(framing, dc_id, secret.key)); + init_sent_ = true; // the protocol tag is inside the obfuscated handshake, never sent as plain bytes + transport_ = Transport(TransportKind::Intermediate); + try { + send_all(obfuscated_->handshake()); + } catch (const ConnectionError& e) { + close(); + throw ProxyError(e.what(), true); + } +} + void Connection::close() noexcept { if (fd_ >= 0) { ::close(fd_); fd_ = -1; } + obfuscated_.reset(); } void Connection::send_all(const Bytes& data) { @@ -72,6 +172,7 @@ void Connection::send_all(const Bytes& data) { const ssize_t n = ::send(fd_, data.data() + sent, data.size() - sent, MSG_NOSIGNAL); if (n < 0) { if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) throw ConnectionTimeout("send timed out"); throw ConnectionError(std::string("send: ") + std::strerror(errno)); } sent += static_cast(n); @@ -82,6 +183,10 @@ void Connection::send_packet(const Bytes& packet) { if (fd_ < 0) { throw ConnectionError("not connected"); } + if (obfuscated_) { + send_all(obfuscated_->encrypt(obfuscated_->frame(packet))); + return; + } Bytes out; if (!init_sent_) { out = transport_.init_bytes(); @@ -99,9 +204,11 @@ void Connection::set_receive_timeout(int milliseconds) { timeval tv{}; tv.tv_sec = milliseconds / 1000; tv.tv_usec = (milliseconds % 1000) * 1000; - if (::setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) { + if (::setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0 || + ::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) != 0) { throw ConnectionError(std::string("setsockopt: ") + std::strerror(errno)); } + io_timeout_ms_ = milliseconds; } Bytes Connection::receive_packet() { @@ -122,7 +229,11 @@ Bytes Connection::receive_packet() { if (n == 0) { throw ConnectionError("connection closed by server"); } - transport_.feed(Bytes(buf, buf + n)); + Bytes chunk(buf, buf + n); + if (obfuscated_) { + chunk = obfuscated_->decrypt(chunk); + } + transport_.feed(chunk); } } diff --git a/src/mtproto/obfuscated.cpp b/src/mtproto/obfuscated.cpp new file mode 100644 index 0000000..75c33f1 --- /dev/null +++ b/src/mtproto/obfuscated.cpp @@ -0,0 +1,190 @@ +#include "cppgram/mtproto/obfuscated.h" + +#include + +#include +#include +#include + +#include "cppgram/mtproto/crypto.h" + +namespace cppgram::mtproto { +namespace { + +constexpr std::size_t kTagPos = 56; +constexpr std::size_t kDcPos = 60; +constexpr std::uint8_t kTagIntermediate[4] = {0xee, 0xee, 0xee, 0xee}; +constexpr std::uint8_t kTagPadded[4] = {0xdd, 0xdd, 0xdd, 0xdd}; +constexpr std::size_t kMaxPadding = 15; + +Bytes make_key(const Bytes& raw_key, const std::array& secret) { + return sha256(concat(raw_key, Bytes(secret.begin(), secret.end()))); +} + +Bytes slice_range(const Bytes& data, std::size_t from, std::size_t to) { + return Bytes(data.begin() + static_cast(from), data.begin() + static_cast(to)); +} + +// Derives the forward (client->proxy) and reverse (proxy->client) keys from a 64-byte handshake. +struct DirectionKeys { + Bytes tx_key; + Bytes tx_iv; + Bytes rx_key; + Bytes rx_iv; +}; + +DirectionKeys derive_keys(const Bytes& init, const std::array& secret) { + DirectionKeys keys; + keys.tx_key = make_key(slice_range(init, 8, 40), secret); + keys.tx_iv = slice_range(init, 40, 56); + Bytes reversed(init.rbegin(), init.rend()); + keys.rx_key = make_key(slice_range(reversed, 8, 40), secret); + keys.rx_iv = slice_range(reversed, 40, 56); + return keys; +} + +bool has_prefix(const Bytes& data, const std::uint8_t* prefix, std::size_t n) { + return std::memcmp(data.data(), prefix, n) == 0; +} + +} // namespace + +struct CtrStream::Impl { + EVP_CIPHER_CTX* ctx = nullptr; + ~Impl() { + if (ctx != nullptr) EVP_CIPHER_CTX_free(ctx); + } +}; + +CtrStream::CtrStream(const Bytes& key, const Bytes& iv) : impl_(std::make_unique()) { + if (key.size() != 32 || iv.size() != 16) { + throw std::invalid_argument("obfuscated transport needs a 32-byte key and a 16-byte iv"); + } + impl_->ctx = EVP_CIPHER_CTX_new(); + if (impl_->ctx == nullptr) throw std::runtime_error("EVP_CIPHER_CTX_new failed"); + if (EVP_CipherInit_ex(impl_->ctx, EVP_aes_256_ctr(), nullptr, key.data(), iv.data(), 1) != 1) { + throw std::runtime_error("AES-256-CTR init failed"); + } +} + +CtrStream::~CtrStream() = default; +CtrStream::CtrStream(CtrStream&&) noexcept = default; +CtrStream& CtrStream::operator=(CtrStream&&) noexcept = default; + +Bytes CtrStream::apply(const Bytes& data) { + Bytes out(data.size()); + if (data.empty()) return out; + int len = 0; + if (EVP_CipherUpdate(impl_->ctx, out.data(), &len, data.data(), static_cast(data.size())) != 1 || + static_cast(len) != data.size()) { + throw std::runtime_error("AES-256-CTR update failed"); + } + return out; +} + +bool obfuscated_init_valid(const Bytes& init) { + if (init.size() != kObfuscatedInitSize) return false; + if (init[0] == 0xef) return false; + static constexpr std::uint8_t kHead[4] = {'H', 'E', 'A', 'D'}; + static constexpr std::uint8_t kPost[4] = {'P', 'O', 'S', 'T'}; + static constexpr std::uint8_t kGet[4] = {'G', 'E', 'T', ' '}; + static constexpr std::uint8_t kTls[4] = {0x16, 0x03, 0x01, 0x02}; + if (has_prefix(init, kHead, 4) || has_prefix(init, kPost, 4) || has_prefix(init, kGet, 4)) return false; + if (has_prefix(init, kTagIntermediate, 4) || has_prefix(init, kTagPadded, 4) || has_prefix(init, kTls, 4)) { + return false; + } + if (init[4] == 0 && init[5] == 0 && init[6] == 0 && init[7] == 0) return false; + return true; +} + +ObfuscatedClient ObfuscatedClient::create(ObfuscatedFraming framing, std::int16_t dc_id, + const std::array& secret, + const std::optional& init_override) { + Bytes init; + if (init_override) { + if (!obfuscated_init_valid(*init_override)) { + throw std::invalid_argument("obfuscated init override is not a valid handshake"); + } + init = *init_override; + } else { + for (;;) { + init = random_bytes(kObfuscatedInitSize); + if (obfuscated_init_valid(init)) break; + } + } + + const std::uint8_t* tag = framing == ObfuscatedFraming::Intermediate ? kTagIntermediate : kTagPadded; + std::memcpy(init.data() + kTagPos, tag, 4); + const auto dc_unsigned = static_cast(dc_id); + init[kDcPos] = static_cast(dc_unsigned & 0xFF); + init[kDcPos + 1] = static_cast(dc_unsigned >> 8); + + DirectionKeys keys = derive_keys(init, secret); + CtrStream tx(keys.tx_key, keys.tx_iv); + CtrStream rx(keys.rx_key, keys.rx_iv); + + // The tail (tag and dc) is encrypted with the first 8 keystream bytes after the 56 raw bytes. Advancing + // the TX stream over all 64 bytes leaves it positioned at the first data byte. + const Bytes keystream = tx.apply(Bytes(kObfuscatedInitSize, 0)); + Bytes handshake = init; + for (std::size_t i = kTagPos; i < kObfuscatedInitSize; ++i) { + handshake[i] = static_cast(init[i] ^ keystream[i]); + } + return ObfuscatedClient(framing, std::move(handshake), std::move(tx), std::move(rx)); +} + +Bytes ObfuscatedClient::frame(const Bytes& payload) const { + Bytes out; + if (framing_ == ObfuscatedFraming::Intermediate) { + out.reserve(4 + payload.size()); + const auto len = static_cast(payload.size()); + for (int i = 0; i < 4; ++i) out.push_back(static_cast(len >> (8 * i))); + out.insert(out.end(), payload.begin(), payload.end()); + return out; + } + const std::size_t pad = random_bytes(1)[0] % (kMaxPadding + 1); + const Bytes padding = random_bytes(pad); + const auto len = static_cast(payload.size() + pad); + out.reserve(4 + payload.size() + pad); + for (int i = 0; i < 4; ++i) out.push_back(static_cast(len >> (8 * i))); + out.insert(out.end(), payload.begin(), payload.end()); + out.insert(out.end(), padding.begin(), padding.end()); + return out; +} + +Bytes ObfuscatedClient::encrypt(const Bytes& data) { + return tx_.apply(data); +} + +Bytes ObfuscatedClient::decrypt(const Bytes& data) { + return rx_.apply(data); +} + +ObfuscatedServerView parse_obfuscated_handshake(const Bytes& handshake, const std::array& secret) { + if (handshake.size() != kObfuscatedInitSize) { + throw std::invalid_argument("obfuscated handshake must be 64 bytes"); + } + DirectionKeys keys = derive_keys(handshake, secret); + CtrStream from_client(keys.tx_key, keys.tx_iv); + CtrStream to_client(keys.rx_key, keys.rx_iv); + + const Bytes keystream = from_client.apply(Bytes(kObfuscatedInitSize, 0)); + Bytes tail(kObfuscatedInitSize - kTagPos); + for (std::size_t i = kTagPos; i < kObfuscatedInitSize; ++i) { + tail[i - kTagPos] = static_cast(handshake[i] ^ keystream[i]); + } + + ObfuscatedFraming framing; + if (std::equal(tail.begin(), tail.begin() + 4, kTagIntermediate)) { + framing = ObfuscatedFraming::Intermediate; + } else if (std::equal(tail.begin(), tail.begin() + 4, kTagPadded)) { + framing = ObfuscatedFraming::PaddedIntermediate; + } else { + throw std::invalid_argument("obfuscated handshake has an unknown protocol tag"); + } + const auto dc_unsigned = static_cast(tail[4] | (tail[5] << 8)); + return ObfuscatedServerView{framing, static_cast(dc_unsigned), std::move(from_client), + std::move(to_client)}; +} + +} // namespace cppgram::mtproto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f815f02..211845b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(ENABLE_COVERAGE) target_link_options(cppgram_bot_test PRIVATE --coverage) endif() -add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/proxy_secret_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp mtproto/session_string_encrypted_test.cpp) +add_executable(cppgram_mtproto_test mtproto/mtproto_test.cpp mtproto/handshake_test.cpp mtproto/session_test.cpp mtproto/highlevel_test.cpp mtproto/parser_test.cpp mtproto/storage_test.cpp mtproto/srp_test.cpp mtproto/transport_test.cpp mtproto/retry_test.cpp mtproto/update_gate_test.cpp mtproto/auth_state_test.cpp mtproto/transfer_test.cpp mtproto/cancel_test.cpp mtproto/cancel_stress_test.cpp mtproto/channel_pts_test.cpp mtproto/session_crypto_test.cpp mtproto/socks5_test.cpp mtproto/proxy_secret_test.cpp mtproto/obfuscated_test.cpp mtproto/connection_net_test.cpp mtproto/session_encrypted_test.cpp mtproto/sqlite_encrypted_test.cpp mtproto/session_string_encrypted_test.cpp) target_link_libraries(cppgram_mtproto_test PRIVATE cppgram::cppgram GTest::gtest_main) include(GoogleTest) diff --git a/tests/mtproto/connection_net_test.cpp b/tests/mtproto/connection_net_test.cpp new file mode 100644 index 0000000..36ee36c --- /dev/null +++ b/tests/mtproto/connection_net_test.cpp @@ -0,0 +1,311 @@ +// Connection behaviour over real loopback sockets with scripted peers: disconnects, reconnects, timeouts, +// refused connections, and the obfuscated proxy path end to end. No external network access is needed. +// The obfuscated peer is written from the same layout rules as obfuscated.h, so it checks the wiring and +// framing, not compatibility with Telegram's proxy servers. +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/connection.h" +#include "cppgram/mtproto/obfuscated.h" +#include "cppgram/mtproto/proxy_secret.h" + +using namespace cppgram::mtproto; + +namespace { + +class Fd { +public: + explicit Fd(int fd = -1) : fd_(fd) {} + ~Fd() { if (fd_ >= 0) ::close(fd_); } + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + Fd(Fd&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; } + Fd& operator=(Fd&& other) noexcept { + if (this != &other) { + if (fd_ >= 0) ::close(fd_); + fd_ = other.fd_; + other.fd_ = -1; + } + return *this; + } + int get() const { return fd_; } + +private: + int fd_; +}; + +// Listening loopback socket on an ephemeral port. +Fd listen_loopback(std::uint16_t& port) { + Fd fd(::socket(AF_INET, SOCK_STREAM, 0)); + int one = 1; + ::setsockopt(fd.get(), SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (::bind(fd.get(), reinterpret_cast(&addr), sizeof(addr)) != 0 || ::listen(fd.get(), 4) != 0) { + throw std::runtime_error("test listener setup failed"); + } + socklen_t len = sizeof(addr); + ::getsockname(fd.get(), reinterpret_cast(&addr), &len); + port = ntohs(addr.sin_port); + return fd; +} + +// Port with nothing listening: bind, read the port, close. +std::uint16_t closed_loopback_port() { + std::uint16_t port = 0; + Fd fd = listen_loopback(port); + return port; +} + +void read_exact(int fd, std::uint8_t* out, std::size_t n) { + std::size_t got = 0; + while (got < n) { + const ssize_t r = ::recv(fd, out + got, n - got, 0); + if (r <= 0) throw std::runtime_error("scripted peer: client went away"); + got += static_cast(r); + } +} + +Bytes read_bytes(int fd, std::size_t n) { + Bytes out(n); + read_exact(fd, out.data(), n); + return out; +} + +void send_bytes(int fd, const Bytes& data) { + std::size_t sent = 0; + while (sent < data.size()) { + const ssize_t n = ::send(fd, data.data() + sent, data.size() - sent, MSG_NOSIGNAL); + if (n <= 0) throw std::runtime_error("scripted peer: send failed"); + sent += static_cast(n); + } +} + +const char* kSecretHex = "11223344556677889900aabbccddeeff"; + +std::array secret_bytes() { + return parse_proxy_secret(kSecretHex).key; +} + +} // namespace + +TEST(ConnectionNet, DirectConnectToClosedPortIsRefusedNotTimedOut) { + const std::uint16_t port = closed_loopback_port(); + Connection conn; + EXPECT_THROW(conn.connect("127.0.0.1", port), ConnectionError); + EXPECT_FALSE(conn.is_open()); +} + +TEST(ConnectionNet, DirectConnectSucceedsAndCanReconnectAfterDrop) { + std::uint16_t port = 0; + Fd listener = listen_loopback(port); + Connection conn(TransportKind::Intermediate); + conn.set_connect_timeout(2000); + conn.connect("127.0.0.1", port); + EXPECT_TRUE(conn.is_open()); + + // Peer accepts and drops the connection without replying: the client must see a closed connection. + std::thread peer([&] { + const int c = ::accept(listener.get(), nullptr, nullptr); + std::uint8_t tmp[64]; + ::recv(c, tmp, sizeof(tmp), 0); + ::close(c); + }); + conn.send_packet(Bytes{1, 2, 3, 4}); + conn.set_receive_timeout(3000); + EXPECT_THROW(conn.receive_packet(), ConnectionError); + peer.join(); + conn.close(); + EXPECT_FALSE(conn.is_open()); + + // Reconnect on the same Connection object to a live peer that answers. + std::thread second([&] { + const int c = ::accept(listener.get(), nullptr, nullptr); + std::uint8_t tmp[256]; + ::recv(c, tmp, sizeof(tmp), 0); + // Intermediate framing: length word then payload. + const Bytes reply{4, 0, 0, 0, 'o', 'k', '!', '!'}; + send_bytes(c, reply); + ::close(c); + }); + conn.connect("127.0.0.1", port); + conn.set_receive_timeout(3000); + conn.send_packet(Bytes{9, 9, 9, 9}); + EXPECT_EQ(conn.receive_packet(), (Bytes{'o', 'k', '!', '!'})); + second.join(); +} + +TEST(ConnectionNet, SilentPeerTriggersReceiveTimeout) { + std::uint16_t port = 0; + Fd listener = listen_loopback(port); + std::thread peer([&] { + const int c = ::accept(listener.get(), nullptr, nullptr); + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + ::close(c); + }); + Connection conn; + conn.connect("127.0.0.1", port); + conn.set_receive_timeout(200); + const auto start = std::chrono::steady_clock::now(); + EXPECT_THROW(conn.receive_packet(), ConnectionTimeout); + const auto elapsed = std::chrono::steady_clock::now() - start; + EXPECT_LT(elapsed, std::chrono::milliseconds(1200)); + peer.join(); +} + +TEST(ConnectionNet, ConnectTimeoutIsEnforcedWhenAcceptQueueIsFull) { + // A listener with a zero-length accept queue, already filled by two clients, drops further SYNs on Linux, + // so a new connect stays in progress. The connect must give up at the configured timeout. + std::uint16_t port = 0; + Fd listener = listen_loopback(port); + ::listen(listener.get(), 0); + // Fillers connect without blocking: once the queue is full a blocking connect would hang for minutes. + Fd filler1(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)); + Fd filler2(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(port); + ::connect(filler1.get(), reinterpret_cast(&addr), sizeof(addr)); + ::connect(filler2.get(), reinterpret_cast(&addr), sizeof(addr)); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + Connection conn; + conn.set_connect_timeout(300); + const auto start = std::chrono::steady_clock::now(); + bool timed_out = false; + try { + conn.connect("127.0.0.1", port); + } catch (const ConnectionTimeout&) { + timed_out = true; + } catch (const ConnectionError&) { + // Some kernels refuse instead of dropping the SYN. Still a bounded failure, checked below. + } + const auto elapsed = std::chrono::steady_clock::now() - start; + if (!timed_out && conn.is_open()) { + GTEST_SKIP() << "kernel completed the connection despite a full accept queue"; + } + EXPECT_LT(elapsed, std::chrono::seconds(5)); + EXPECT_FALSE(conn.is_open()); +} + +TEST(ConnectionNet, InvalidIpv4LiteralIsRejectedWithoutConnecting) { + Connection conn; + EXPECT_THROW(conn.connect("not-an-ip", 443), ConnectionError); +} + +TEST(ObfuscatedNet, ConnectionToClosedProxyIsTransientProxyError) { + const std::uint16_t port = closed_loopback_port(); + Connection conn; + try { + conn.connect_obfuscated("127.0.0.1", port, parse_proxy_secret(kSecretHex), 2, + std::chrono::milliseconds(1000)); + FAIL() << "expected ProxyError"; + } catch (const ProxyError& e) { + EXPECT_TRUE(e.transient()); + } +} + +TEST(ObfuscatedNet, PlainSecretRoundTripThroughScriptedProxy) { + std::uint16_t port = 0; + Fd listener = listen_loopback(port); + const auto key = secret_bytes(); + + std::thread proxy([&] { + const int c = ::accept(listener.get(), nullptr, nullptr); + const Bytes handshake = read_bytes(c, kObfuscatedInitSize); + auto view = parse_obfuscated_handshake(handshake, key); + EXPECT_EQ(view.framing, ObfuscatedFraming::Intermediate); + EXPECT_EQ(view.dc_id, 5); + + const Bytes len_wire = read_bytes(c, 4); + const Bytes len_plain = view.from_client.apply(len_wire); + const std::uint32_t len = len_plain[0] | (len_plain[1] << 8) | (len_plain[2] << 16) | + (static_cast(len_plain[3]) << 24); + const Bytes payload = view.from_client.apply(read_bytes(c, len)); + EXPECT_EQ(payload, (Bytes{'p', 'i', 'n', 'g'})); + + const Bytes reply_frame{4, 0, 0, 0, 'p', 'o', 'n', 'g'}; + send_bytes(c, view.to_client.apply(reply_frame)); + ::close(c); + }); + + Connection conn; + conn.set_connect_timeout(2000); + conn.connect_obfuscated("127.0.0.1", port, parse_proxy_secret(kSecretHex), 5, + std::chrono::milliseconds(2000)); + conn.set_receive_timeout(3000); + conn.send_packet(Bytes{'p', 'i', 'n', 'g'}); + EXPECT_EQ(conn.receive_packet(), (Bytes{'p', 'o', 'n', 'g'})); + proxy.join(); +} + +TEST(ObfuscatedNet, PaddedSecretFramingIsAcceptedByScriptedProxy) { + std::uint16_t port = 0; + Fd listener = listen_loopback(port); + const auto key = secret_bytes(); + + std::thread proxy([&] { + const int c = ::accept(listener.get(), nullptr, nullptr); + auto view = parse_obfuscated_handshake(read_bytes(c, kObfuscatedInitSize), key); + EXPECT_EQ(view.framing, ObfuscatedFraming::PaddedIntermediate); + const Bytes len_plain = view.from_client.apply(read_bytes(c, 4)); + const std::uint32_t len = len_plain[0] | (len_plain[1] << 8) | (len_plain[2] << 16) | + (static_cast(len_plain[3]) << 24); + const Bytes body = view.from_client.apply(read_bytes(c, len)); + // The body is the payload followed by 0..15 bytes of padding. + EXPECT_GE(len, 8u); + EXPECT_LE(len, 8u + 15u); + EXPECT_EQ(Bytes(body.begin(), body.begin() + 8), (Bytes{1, 2, 3, 4, 5, 6, 7, 8})); + ::close(c); + }); + + Connection conn; + conn.connect_obfuscated("127.0.0.1", port, parse_proxy_secret("dd" + std::string(kSecretHex)), 2, + std::chrono::milliseconds(2000)); + conn.send_packet(Bytes{1, 2, 3, 4, 5, 6, 7, 8}); + proxy.join(); +} + +TEST(ObfuscatedNet, ProxyThatDropsAfterHandshakeIsDetected) { + std::uint16_t port = 0; + Fd listener = listen_loopback(port); + std::thread proxy([&] { + const int c = ::accept(listener.get(), nullptr, nullptr); + read_bytes(c, kObfuscatedInitSize); + ::close(c); + }); + Connection conn; + conn.connect_obfuscated("127.0.0.1", port, parse_proxy_secret(kSecretHex), 2, + std::chrono::milliseconds(2000)); + conn.set_receive_timeout(3000); + proxy.join(); + try { + conn.receive_packet(); + FAIL() << "expected the dropped proxy connection to fail"; + } catch (const ConnectionTimeout&) { + FAIL() << "a closed connection must not look like a timeout"; + } catch (const ConnectionError& e) { + EXPECT_NE(std::string(e.what()).find("closed"), std::string::npos); + } +} + +TEST(ObfuscatedNet, FakeTlsSecretIsRejectedBeforeConnecting) { + Connection conn; + const std::string hex = "ee" + std::string(kSecretHex) + "6578616d706c652e636f6d"; + EXPECT_THROW(conn.connect_obfuscated("127.0.0.1", 9, parse_proxy_secret(hex), 2, + std::chrono::milliseconds(200)), + std::invalid_argument); +} diff --git a/tests/mtproto/obfuscated_test.cpp b/tests/mtproto/obfuscated_test.cpp new file mode 100644 index 0000000..fa5acb0 --- /dev/null +++ b/tests/mtproto/obfuscated_test.cpp @@ -0,0 +1,202 @@ +// Obfuscated (MTProto proxy) transport: handshake layout, key derivation, framing and error handling. +// Offline only. The expected values come from the layout rules in obfuscated.h, not from captured proxy traffic, +// so these tests check internal consistency and the server-side decode path. They do NOT prove compatibility +// with the Telegram proxy servers. +#include + +#include +#include +#include +#include +#include + +#include "cppgram/mtproto/connection.h" +#include "cppgram/mtproto/crypto.h" +#include "cppgram/mtproto/obfuscated.h" +#include "cppgram/mtproto/proxy_secret.h" + +using namespace cppgram::mtproto; + +namespace { + +std::array secret_a() { + std::array s{}; + for (std::size_t i = 0; i < s.size(); ++i) s[i] = static_cast(0x11 * (i + 1)); + return s; +} + +std::array secret_b() { + auto s = secret_a(); + s[15] ^= 0x01; + return s; +} + +// A fixed, valid 64-byte init so the tests are deterministic. +Bytes fixed_init() { + Bytes init(64); + for (std::size_t i = 0; i < init.size(); ++i) init[i] = static_cast(i * 7 + 3); + init[0] = 0x42; // not 0xef + init[4] = 0x01; // bytes 4..8 not all zero + return init; +} + +} // namespace + +TEST(ObfuscatedInit, RejectsReservedPrefixesAndZeroBytes) { + Bytes ok = fixed_init(); + EXPECT_TRUE(obfuscated_init_valid(ok)); + + Bytes bad = ok; + bad[0] = 0xef; + EXPECT_FALSE(obfuscated_init_valid(bad)); + + const std::vector> prefixes = { + {'H', 'E', 'A', 'D'}, {'P', 'O', 'S', 'T'}, {'G', 'E', 'T', ' '}, + {0xee, 0xee, 0xee, 0xee}, {0xdd, 0xdd, 0xdd, 0xdd}, {0x16, 0x03, 0x01, 0x02}}; + for (const auto& p : prefixes) { + Bytes candidate = ok; + std::copy(p.begin(), p.end(), candidate.begin()); + EXPECT_FALSE(obfuscated_init_valid(candidate)); + } + + Bytes zeros = ok; + zeros[4] = zeros[5] = zeros[6] = zeros[7] = 0; + EXPECT_FALSE(obfuscated_init_valid(zeros)); + + EXPECT_FALSE(obfuscated_init_valid(Bytes(63, 0x42))); +} + +TEST(ObfuscatedInit, OverrideMustBeValid) { + Bytes bad = fixed_init(); + bad[0] = 0xef; + EXPECT_THROW(ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), bad), + std::invalid_argument); +} + +TEST(ObfuscatedHandshake, ServerDecodesFramingAndDcForBothModes) { + for (const std::int16_t dc : {std::int16_t{1}, std::int16_t{2}, std::int16_t{5}, std::int16_t{-1}}) { + for (const auto framing : {ObfuscatedFraming::Intermediate, ObfuscatedFraming::PaddedIntermediate}) { + auto client = ObfuscatedClient::create(framing, dc, secret_a(), fixed_init()); + ASSERT_EQ(client.handshake().size(), kObfuscatedInitSize); + auto server = parse_obfuscated_handshake(client.handshake(), secret_a()); + EXPECT_EQ(server.framing, framing); + EXPECT_EQ(server.dc_id, dc); + } + } +} + +TEST(ObfuscatedHandshake, TagIsEncryptedOnTheWire) { + // The plain tag 0xeeeeeeee must not appear in the clear at offset 56 of the handshake. + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + const auto& h = client.handshake(); + EXPECT_FALSE(h[56] == 0xee && h[57] == 0xee && h[58] == 0xee && h[59] == 0xee); + // Bytes before the tail are sent raw. + const Bytes init = fixed_init(); + EXPECT_TRUE(std::equal(init.begin(), init.begin() + 56, h.begin())); +} + +TEST(ObfuscatedHandshake, WrongSecretDoesNotDecode) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + EXPECT_THROW(parse_obfuscated_handshake(client.handshake(), secret_b()), std::invalid_argument); +} + +TEST(ObfuscatedHandshake, TamperedTailIsRejected) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + Bytes tampered = client.handshake(); + tampered[57] ^= 0x80; // changes the decoded tag + EXPECT_THROW(parse_obfuscated_handshake(tampered, secret_a()), std::invalid_argument); +} + +TEST(ObfuscatedHandshake, WrongSizeIsRejected) { + EXPECT_THROW(parse_obfuscated_handshake(Bytes(63, 0), secret_a()), std::invalid_argument); + EXPECT_THROW(parse_obfuscated_handshake(Bytes(65, 0), secret_a()), std::invalid_argument); +} + +TEST(ObfuscatedStream, ClientToServerRoundTripAcrossSeveralWrites) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + auto server = parse_obfuscated_handshake(client.handshake(), secret_a()); + + const std::vector messages = { + Bytes{1, 2, 3, 4}, Bytes(100, 0xAB), Bytes(17, 0x00), Bytes(4096, 0x5C), Bytes{9}}; + for (const auto& m : messages) { + const Bytes wire = client.encrypt(m); + EXPECT_EQ(wire.size(), m.size()); + EXPECT_EQ(server.from_client.apply(wire), m); + } +} + +TEST(ObfuscatedStream, ServerToClientRoundTrip) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::PaddedIntermediate, 5, secret_a(), fixed_init()); + auto server = parse_obfuscated_handshake(client.handshake(), secret_a()); + + const Bytes reply(300, 0x3C); + EXPECT_EQ(client.decrypt(server.to_client.apply(reply)), reply); + const Bytes reply2{7, 7, 7}; + EXPECT_EQ(client.decrypt(server.to_client.apply(reply2)), reply2); +} + +TEST(ObfuscatedStream, DirectionsUseDifferentKeystreams) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + const Bytes zeros(32, 0); + const Bytes out = client.encrypt(zeros); + const Bytes in = client.decrypt(zeros); + EXPECT_NE(out, in); +} + +TEST(ObfuscatedStream, DataAfterHandshakeRoundTripsOnTheSharedStream) { + // The TX stream is advanced over the handshake, so encrypting zeros must not reproduce the tail keystream. + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + auto server = parse_obfuscated_handshake(client.handshake(), secret_a()); + const Bytes data(8, 0); + const Bytes wire = client.encrypt(data); + EXPECT_EQ(server.from_client.apply(wire), data); +} + +TEST(ObfuscatedFrame, IntermediateFramingIsLengthPrefixed) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::Intermediate, 2, secret_a(), fixed_init()); + const Bytes payload{1, 2, 3, 4, 5, 6, 7, 8}; + const Bytes frame = client.frame(payload); + ASSERT_EQ(frame.size(), 4 + payload.size()); + EXPECT_EQ(frame[0], 8); + EXPECT_EQ(frame[1], 0); + EXPECT_EQ(frame[2], 0); + EXPECT_EQ(frame[3], 0); + EXPECT_EQ(Bytes(frame.begin() + 4, frame.end()), payload); +} + +TEST(ObfuscatedFrame, PaddedFramingAddsZeroToFifteenBytes) { + auto client = ObfuscatedClient::create(ObfuscatedFraming::PaddedIntermediate, 2, secret_a(), fixed_init()); + const Bytes payload(20, 0x77); + for (int i = 0; i < 64; ++i) { + const Bytes frame = client.frame(payload); + const std::uint32_t len = frame[0] | (frame[1] << 8) | (frame[2] << 16) | (static_cast(frame[3]) << 24); + ASSERT_GE(len, payload.size()); + ASSERT_LE(len, payload.size() + 15); + ASSERT_EQ(frame.size(), 4 + len); + EXPECT_EQ(Bytes(frame.begin() + 4, frame.begin() + 4 + payload.size()), payload); + } +} + +TEST(ObfuscatedCtr, RejectsBadKeyOrIvSizes) { + EXPECT_THROW(CtrStream(Bytes(16, 0), Bytes(16, 0)), std::invalid_argument); + EXPECT_THROW(CtrStream(Bytes(32, 0), Bytes(8, 0)), std::invalid_argument); +} + +TEST(ObfuscatedCtr, EmptyInputGivesEmptyOutputAndIsSymmetric) { + CtrStream a(Bytes(32, 1), Bytes(16, 2)); + EXPECT_TRUE(a.apply(Bytes{}).empty()); + CtrStream enc(Bytes(32, 1), Bytes(16, 2)); + CtrStream dec(Bytes(32, 1), Bytes(16, 2)); + const Bytes plain{'o', 'k'}; + EXPECT_EQ(dec.apply(enc.apply(plain)), plain); +} + +TEST(ProxySecretForConnection, FakeTlsIsRejectedBeforeAnyNetworkUse) { + // Parsing succeeds for an ee secret; the connection layer refuses it without touching the network. + const std::string hex = "ee" + std::string(32, 'a') + "6578616d706c652e636f6d"; // domain hex + const ProxySecret secret = parse_proxy_secret(hex); + ASSERT_EQ(secret.kind, ProxySecretKind::FakeTls); + Connection conn; + EXPECT_THROW(conn.connect_obfuscated("127.0.0.1", 9, secret, 2, std::chrono::milliseconds(200)), + std::invalid_argument); +} diff --git a/tests/mtproto/session_test.cpp b/tests/mtproto/session_test.cpp index 71f71fb..910c565 100644 --- a/tests/mtproto/session_test.cpp +++ b/tests/mtproto/session_test.cpp @@ -8,8 +8,11 @@ #include #include #include +#include #include +#include "cppgram/mtproto/cancel.h" + #include "cppgram/client.h" #include "cppgram/mtproto/connection.h" #include "cppgram/mtproto/crypto.h" @@ -706,3 +709,23 @@ TEST(Replay, DuplicateServerMessageIsNotProcessedTwice) { EXPECT_EQ(second.read_uint32(), ty::updateShort::kId); EXPECT_EQ(second.read_uint32(), 2u); } + +TEST(Session, FloodWaitSleepCanBeCancelledWithoutResending) { + const Bytes key = random_bytes(256); + FloodServer server(key, "FLOOD_WAIT_30"); + MtSession session(server, SessionData{2, key, 0x1111222233334444ull, 0}); + RetryPolicy policy; + policy.max_flood_wait = std::chrono::seconds(60); + session.set_retry_policy(policy); + std::stop_source stop; + session.set_stop_token(stop.get_token()); + std::thread canceller([&stop] { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + stop.request_stop(); + }); + const auto start = std::chrono::steady_clock::now(); + EXPECT_THROW(session.invoke(Bytes{0x01, 0x02, 0x03, 0x04}), OperationCancelled); + canceller.join(); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(5)); + EXPECT_EQ(server.requests(), 1); // the request was not resent after the cancelled wait +} From ae82b0a2a494ed803e0fd2b49fca87bb8b2099d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:03:47 +0000 Subject: [PATCH 23/25] tools: add timed libFuzzer runner for the three fuzz targets Builds fuzz_tl_reader, fuzz_session_string and fuzz_html_parser with clang-18 and runs each for a fixed budget (default 600 s), logging to build-fuzz/logs and a summary with wall time. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- tools/run_fuzz_long.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100755 tools/run_fuzz_long.sh diff --git a/tools/run_fuzz_long.sh b/tools/run_fuzz_long.sh new file mode 100755 index 0000000..8afe573 --- /dev/null +++ b/tools/run_fuzz_long.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Builds the libFuzzer targets with clang and runs each one for a fixed time budget. +# Usage: tools/run_fuzz_long.sh [seconds_per_target] (default 600). Output: build-fuzz/logs/.log +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SECONDS_PER_TARGET="${1:-600}" +BUILD="$ROOT/build-fuzz" +LOGS="$BUILD/logs" +mkdir -p "$LOGS" +cmake -S "$ROOT" -B "$BUILD" -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_C_COMPILER=clang-18 \ + -DENABLE_TESTS=OFF -DBUILD_FUZZERS=ON -DCMAKE_BUILD_TYPE=Release +cmake --build "$BUILD" -j1 --target fuzz_tl_reader fuzz_session_string fuzz_html_parser +for target in fuzz_tl_reader fuzz_session_string fuzz_html_parser; do + corpus="$BUILD/corpus/$target" + mkdir -p "$corpus" + start=$(date +%s) + set +e + "$BUILD/$target" "$corpus" -max_total_time="$SECONDS_PER_TARGET" -timeout=10 -print_final_stats=1 \ + > "$LOGS/$target.log" 2>&1 + rc=$? + set -e + end=$(date +%s) + echo "$target exit=$rc wall_seconds=$((end - start))" | tee -a "$LOGS/summary.txt" +done From fd43f3340e42dd08b53ca2b9d98b9bd55971d4f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:47:04 +0000 Subject: [PATCH 24/25] docs: changelog entry for MTProto proxy transport and connect timeouts Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e4cf0..d42db46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - `connect()` resets the state to `Unauthenticated`; `disconnect()` sets `Disconnected`; `run()` re-probes an authorized session after a reconnect. - `login_bot` and `log_out` update the state. See `guides/AUTHENTICATION.md` (user login not live-tested). +### MTProto proxy and connection timeouts +- New: obfuscated MTProto proxy transport (`cppgram/mtproto/obfuscated.h`) and `ClientOptions::mtproto_proxy` (host, port, hex secret). Plain and padded-intermediate secrets are supported. Fake-TLS (`ee`) secrets are rejected. Offline tests only: live compatibility with Telegram proxy servers is not verified. +- Changed: direct TCP connects are bounded by `ClientOptions::connect_timeout_ms` (non-blocking connect with a deadline). Previously a connect could block for the kernel's full SYN timeout. +- Changed: send timeouts use the same value as receive timeouts. + ### Networking and shutdown - New: cancellable waits (`cppgram/mtproto/cancel.h`). Retry backoff, reconnect backoff and flood-wait sleeps stop when `Client::stop()` is called; `run()` returns cleanly. - `Client::stop()` is safe to call from another thread. A new `run()` starts unstopped. From 23c7583c9730c84de3ccbbfd8166bbfba0b2fc7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:57:33 +0000 Subject: [PATCH 25/25] chore: ignore build-fuzz output directory Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01VdXUzdYQNMHLdQC61Ljxmr --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3159c33..d97e9a5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ bot_workspace/ *.bak build-san/ build-tsan/ +build-fuzz/