From 5fa9101ac077e92971ee3bc76d5a3a7b96839997 Mon Sep 17 00:00:00 2001 From: Neko Life Date: Mon, 14 Oct 2024 07:56:52 +0000 Subject: [PATCH] DAVE user join/part (#1276) --- include/dpp/discordvoiceclient.h | 46 +++- src/dpp/discordvoiceclient.cpp | 17 +- src/dpp/voice/enabled/enabled.h | 13 +- src/dpp/voice/enabled/handle_frame.cpp | 305 ++++++++++++++++++------- src/dpp/voice/enabled/opus.cpp | 2 +- 5 files changed, 277 insertions(+), 106 deletions(-) diff --git a/include/dpp/discordvoiceclient.h b/include/dpp/discordvoiceclient.h index 88bd5f7241..84a9fb985a 100644 --- a/include/dpp/discordvoiceclient.h +++ b/include/dpp/discordvoiceclient.h @@ -166,7 +166,7 @@ enum voice_websocket_opcode_t : uint8_t { voice_client_dave_mls_key_package = 26, voice_client_dave_mls_proposals = 27, voice_client_dave_mls_commit_message = 28, - voice_client_dave_announce_commit_transaction = 29, + voice_client_dave_announce_commit_transition = 29, voice_client_dave_mls_welcome = 30, voice_client_dave_mls_invalid_commit_welcome = 31, }; @@ -206,7 +206,7 @@ struct dave_binary_header_t { [[nodiscard]] std::vector get_data() const; /** - * Get transition ID for process_welcome + * Get transition ID for process_commit and process_welcome * * @return Transition ID */ @@ -215,7 +215,7 @@ struct dave_binary_header_t { private: /** * @brief Transition id, only valid when the opcode is - * welcome state. Use get_transition_id() to obtain value. + * commit and welcome state. Use get_transition_id() to obtain value. */ uint16_t transition_id; }; @@ -478,11 +478,12 @@ class DPP_EXPORT discord_voice_client : public websocket_client std::set dave_mls_user_list; /** - * @brief The list of users that just joined for DAVE + * @brief The list of users that have left the voice channel but + * not yet removed from MLS group. */ - std::set dave_mls_new_user_list; + std::set dave_mls_pending_remove_list; - /** + /** * @brief File descriptor for UDP connection */ dpp::socket fd; @@ -1200,6 +1201,39 @@ class DPP_EXPORT discord_voice_client : public websocket_client * which internally uses scrypt. */ void get_user_privacy_code(const dpp::snowflake user, privacy_code_callback_t callback) const; + + /** + * @brief Notify gateway ready for a DAVE transition. + * + * Fires Voice Ready event when appropriate. + * + * https://daveprotocol.com/#commit-handling + */ + void ready_for_transition(const std::string &data); + + /** + * @brief Reset dave session, send voice_client_dave_mls_invalid_commit_welcome + * payload with current transition Id and our new key package to gateway. + * + * https://daveprotocol.com/#recovery-from-invalid-commit-or-welcome + */ + void recover_from_invalid_commit_welcome(); + + /** + * @brief Execute pending protocol upgrade/downgrade to/from dave. + * @return true if did an upgrade/downgrade + */ + bool execute_pending_upgrade_downgrade(); + + /** + * @brief Reset dave session and prepare initial session group. + */ + void reinit_dave_mls_group(); + + /** + * @brief Process roster map from commit/welcome. + */ + void process_mls_group_rosters(const std::map> &rmap); }; } // namespace dpp diff --git a/src/dpp/discordvoiceclient.cpp b/src/dpp/discordvoiceclient.cpp index ca44b19c06..11f2e10b09 100644 --- a/src/dpp/discordvoiceclient.cpp +++ b/src/dpp/discordvoiceclient.cpp @@ -85,8 +85,9 @@ bool discord_voice_client::is_playing() { } uint16_t dave_binary_header_t::get_transition_id() const { - if (opcode != voice_client_dave_mls_welcome) { - throw dpp::logic_exception("Can't get transition ID from buffer that is not of type voice_client_dave_mls_welcome(30)"); + bool has_transition_id = opcode == voice_client_dave_mls_welcome || opcode == voice_client_dave_announce_commit_transition; + if (!has_transition_id) { + throw dpp::logic_exception("Can't get transition ID from buffer that is not of type voice_client_dave_announce_commit_transition(29) or voice_client_dave_mls_welcome(30)"); } return transition_id; } @@ -98,7 +99,9 @@ dave_binary_header_t::dave_binary_header_t(const std::string& buffer) { seq = (buffer[0] << 8) | buffer[1]; opcode = buffer[2]; transition_id = (buffer[3] << 8) | buffer[4]; - package.assign(buffer.begin() + (opcode == voice_client_dave_mls_welcome ? 5 : 3), buffer.end()); + + bool has_transition_id = opcode == voice_client_dave_mls_welcome || opcode == voice_client_dave_announce_commit_transition; + package.assign(buffer.begin() + (has_transition_id ? 5 : 3), buffer.end()); } std::vector dave_binary_header_t::get_data() const { @@ -129,13 +132,17 @@ void discord_voice_client::get_user_privacy_code(const dpp::snowflake user, priv bool discord_voice_client::is_end_to_end_encrypted() const { #ifdef HAVE_VOICE - if (mls_state == nullptr) { + if (mls_state == nullptr || mls_state->encryptor == nullptr) { return false; } bool has_pending_downgrade = mls_state->pending_transition.is_pending && mls_state->pending_transition.protocol_version != dave_version_1; - return !has_pending_downgrade && !mls_state->privacy_code.empty(); + /* + * A dave_version 0 should be enough to know we're in non-e2ee session, we should also check for pending downgrade and + * whether session encryptor actually has key rachet set to encrypt opus packets. + */ + return !has_pending_downgrade && dave_version != dave_version_none && mls_state->encryptor->has_key_ratchet(); #else return false; #endif diff --git a/src/dpp/voice/enabled/enabled.h b/src/dpp/voice/enabled/enabled.h index 996b249492..ccd7f99067 100644 --- a/src/dpp/voice/enabled/enabled.h +++ b/src/dpp/voice/enabled/enabled.h @@ -87,18 +87,10 @@ struct dave_state { * @brief Our key package */ std::shared_ptr<::mlspp::SignaturePrivateKey> mls_key; - /** - * @brief Cached commit package for use in welcome - */ - std::vector cached_commit; /** * @brief Current transition ID */ uint64_t transition_id{0}; - /** - * @brief True when we have a sending ratchet - */ - bool have_sending_ratchet{false}; /** * @brief Have sent ready event to listeners */ @@ -133,6 +125,11 @@ struct dave_state { * MLS group is not established. */ std::string privacy_code; + + /** + * @brief Cached roster map to track rosters changes. + */ + dave::roster_map cached_roster_map; }; /** diff --git a/src/dpp/voice/enabled/handle_frame.cpp b/src/dpp/voice/enabled/handle_frame.cpp index 0bcc049862..19607d5dce 100644 --- a/src/dpp/voice/enabled/handle_frame.cpp +++ b/src/dpp/voice/enabled/handle_frame.cpp @@ -25,7 +25,6 @@ #include #include #include -#include #include "../../dave/encryptor.h" #include "enabled.h" @@ -65,11 +64,15 @@ void discord_voice_client::update_ratchets(bool force) { } decryptor->second->transition_to_key_ratchet(mls_state->dave_session->get_key_ratchet(user), RATCHET_EXPIRY); } - /* No expiry on sender! It's up to the receiver to decide when to discard their old keys */ - if (mls_state->encryptor && !mls_state->have_sending_ratchet) { + + /* + * Encryptor should always be present on execute transition. + * Should we throw error if it's missing here? + */ + if (mls_state->encryptor) { + /* Updating key rachet should always be done on execute transition. Generally after group member add/remove. */ log(ll_debug, "Setting key ratchet for sending audio..."); mls_state->encryptor->set_key_ratchet(mls_state->dave_session->get_key_ratchet(creator->me.id.str())); - mls_state->have_sending_ratchet = true; } /** @@ -96,66 +99,69 @@ bool discord_voice_client::handle_frame(const std::string &data, ws_opcode opcod dave_binary_header_t dave_header(data); + /* These binaries also contains sequence number we need to save */ + receive_sequence = dave_header.seq; + switch (dave_header.opcode) { case voice_client_dave_mls_external_sender: { log(ll_debug, "voice_client_dave_mls_external_sender"); mls_state->dave_session->set_external_sender(dave_header.get_data()); - mls_state->encryptor = std::make_unique(*creator); - mls_state->decryptors.clear(); } break; case voice_client_dave_mls_proposals: { log(ll_debug, "voice_client_dave_mls_proposals"); - std::optional> response = mls_state->dave_session->process_proposals(dave_header.get_data(), dave_mls_new_user_list); + std::optional> response = mls_state->dave_session->process_proposals(dave_header.get_data(), dave_mls_user_list); if (response.has_value()) { auto r = response.value(); - mls_state->cached_commit = r; r.insert(r.begin(), voice_client_dave_mls_commit_message); this->write(std::string_view(reinterpret_cast(r.data()), r.size()), OP_BINARY); } } break; - case voice_client_dave_announce_commit_transaction: { - log(ll_debug, "voice_client_dave_announce_commit_transaction"); - auto r = mls_state->dave_session->process_commit(mls_state->cached_commit); - update_ratchets(!mls_state->have_sending_ratchet); - if (!creator->on_voice_ready.empty() && !mls_state->done_ready) { - voice_ready_t rdy(nullptr, data); - rdy.voice_client = this; - rdy.voice_channel_id = this->channel_id; - creator->on_voice_ready.call(rdy); - mls_state->done_ready = true; + case voice_client_dave_announce_commit_transition: { + this->mls_state->transition_id = dave_header.get_transition_id(); + log(ll_debug, "voice_client_dave_announce_commit_transition"); + auto r = mls_state->dave_session->process_commit(dave_header.get_data()); + + /* + * We need to do recovery here when we failed processing the message + */ + if (!std::holds_alternative(r)) { + log(ll_debug, "Unable to process commit in transition " + std::to_string(this->mls_state->transition_id)); + + this->recover_from_invalid_commit_welcome(); + break; } + + auto rmap = std::get(r); + this->process_mls_group_rosters(rmap); + + this->ready_for_transition(data); } break; case voice_client_dave_mls_welcome: { this->mls_state->transition_id = dave_header.get_transition_id(); log(ll_debug, "voice_client_dave_mls_welcome with transition id " + std::to_string(this->mls_state->transition_id)); - dave_mls_user_list.erase(creator->me.id.str()); + + /* We should always recognize our own selves, but do we? */ + dave_mls_user_list.insert(this->creator->me.id.str()); + auto r = mls_state->dave_session->process_welcome(dave_header.get_data(), dave_mls_user_list); - update_ratchets(); - log(ll_debug, "Ready to execute transition " + std::to_string(this->mls_state->transition_id)); - json obj = { - { "op", voice_client_dave_transition_ready }, - { - "d", - { - { "transition_id", this->mls_state->transition_id }, - } - } - }; - this->write(obj.dump(-1, ' ', false, json::error_handler_t::replace), OP_TEXT); - if (!mls_state->done_ready) { - if (!creator->on_voice_ready.empty()) { - voice_ready_t rdy(nullptr, data); - rdy.voice_client = this; - rdy.voice_channel_id = this->channel_id; - creator->on_voice_ready.call(rdy); - mls_state->done_ready = true; - } + + /* + * We need to do recovery here when we failed processing the message + */ + if (!r.has_value()) { + log(ll_debug, "Unable to process welcome in transition " + std::to_string(this->mls_state->transition_id)); + + this->recover_from_invalid_commit_welcome(); + break; } + + this->process_mls_group_rosters(r.value()); + this->ready_for_transition(data); } break; default: @@ -207,9 +213,19 @@ bool discord_voice_client::handle_frame(const std::string &data, ws_opcode opcod } break; case voice_opcode_multiple_clients_connect: { - dave_mls_new_user_list = j["d"]["user_ids"]; - dave_mls_user_list.insert(dave_mls_new_user_list.begin(), dave_mls_new_user_list.end()); - log(ll_debug, "New of clients in voice channel: " + std::to_string(dave_mls_new_user_list.size()) + " total is " + std::to_string(dave_mls_user_list.size())); + /** + * @brief The list of users that just joined for DAVE + */ + std::set joining_dave_users = j["d"]["user_ids"]; + + dave_mls_user_list.insert(joining_dave_users.begin(), joining_dave_users.end()); + + /* Remove this user from pending remove list if exist */ + for (const auto &user : joining_dave_users) { + dave_mls_pending_remove_list.erase(user); + } + + log(ll_debug, "New of clients in voice channel: " + std::to_string(joining_dave_users.size()) + " total is " + std::to_string(dave_mls_user_list.size())); } break; case voice_client_dave_mls_invalid_commit_welcome: { @@ -221,54 +237,50 @@ bool discord_voice_client::handle_frame(const std::string &data, ws_opcode opcod log(ll_debug, "voice_client_dave_execute_transition"); this->mls_state->transition_id = j["d"]["transition_id"]; - if (this->mls_state->pending_transition.is_pending) { - if (this->mls_state->transition_id != this->mls_state->pending_transition.id) { - log(ll_debug, "voice_client_dave_execute_transition unexpected transition_id, we never received voice_client_dave_prepare_transition event with this id: " + std::to_string(this->mls_state->pending_transition.id)); - } else { - dave_version = this->mls_state->pending_transition.protocol_version == 1 ? dave_version_1 : dave_version_none; - - if (this->mls_state->pending_transition.protocol_version != 0 && dave_version == dave_version_none) { - log(ll_debug, "voice_client_dave_execute_transition unexpected protocol version: " + std::to_string(this->mls_state->pending_transition.protocol_version)+ " in transition " + std::to_string(this->mls_state->pending_transition.id)); - } - - this->mls_state->privacy_code.clear(); - this->dave_mls_user_list.clear(); - - this->mls_state->pending_transition.is_pending = false; - } + if (this->mls_state->pending_transition.is_pending && this->execute_pending_upgrade_downgrade()) { + break; } + + /* + * Execute transition from a commit/welcome message. + */ + update_ratchets(); } break; /* "The protocol only uses this opcode to indicate when a downgrade to protocol version 0 is upcoming." */ case voice_client_dave_prepare_transition: { - uint64_t transition_id = j["d"]["transition_id"]; + this->mls_state->transition_id = j["d"]["transition_id"]; uint64_t protocol_version = j["d"]["protocol_version"]; - this->mls_state->pending_transition = {transition_id, protocol_version, true}; - log(ll_debug, "voice_client_dave_prepare_transition version=" + std::to_string(protocol_version) + " for transition " + std::to_string(transition_id)); - json obj = { - { "op", voice_client_dave_transition_ready }, - { - "d", + this->mls_state->pending_transition = {this->mls_state->transition_id, protocol_version, true}; + + log(ll_debug, "voice_client_dave_prepare_transition version=" + std::to_string(protocol_version) + " for transition " + std::to_string(this->mls_state->transition_id)); + + if (this->mls_state->transition_id == 0) { + this->execute_pending_upgrade_downgrade(); + } else { + json obj = { + { "op", voice_client_dave_transition_ready }, { - { "transition_id", this->mls_state->transition_id }, + "d", + { + { "transition_id", this->mls_state->transition_id }, + } } - } - }; - this->write(obj.dump(-1, ' ', false, json::error_handler_t::replace), OP_TEXT); + }; + this->write(obj.dump(-1, ' ', false, json::error_handler_t::replace), OP_TEXT); + } } break; case voice_client_dave_prepare_epoch: { uint64_t protocol_version = j["d"]["protocol_version"]; - uint64_t epoch = j["d"]["epoch"]; + uint32_t epoch = j["d"]["epoch"]; log(ll_debug, "voice_client_dave_prepare_epoch version=" + std::to_string(protocol_version) + " for epoch " + std::to_string(epoch)); if (epoch == 1) { - mls_state->dave_session->reset(); - mls_state->dave_session->init(dave::max_protocol_version(), channel_id, creator->me.id.str(), mls_state->mls_key); - auto key_response = mls_state->dave_session->get_marshalled_key_package(); - key_response.insert(key_response.begin(), voice_client_dave_mls_key_package); - this->write(std::string_view(reinterpret_cast(key_response.data()), key_response.size()), OP_BINARY); - mls_state->decryptors.clear(); + /* An epoch 1 is the start of new dave session, update dave_version */ + dave_version = protocol_version == 1 ? dave_version_1 : dave_version_none; + + this->reinit_dave_mls_group(); } } break; @@ -285,13 +297,8 @@ bool discord_voice_client::handle_frame(const std::string &data, ws_opcode opcod ssrc_map.erase(it); } - auto it_dave = dave_mls_user_list.find(j["d"]["user_id"]); - if (it_dave != dave_mls_user_list.end()) { - /* Ongoing MLS session, update everyone's ratchets and VC privacy code */ - dave_mls_user_list.erase(it_dave); - } - /* Remove this user's key ratchet */ - mls_state->decryptors.erase(u_id); + /* Mark this user for remove on immediate upgrade */ + dave_mls_pending_remove_list.insert(u_id.str()); if (!creator->on_voice_client_disconnect.empty()) { voice_client_disconnect_t vcd(nullptr, data); @@ -402,11 +409,8 @@ bool discord_voice_client::handle_frame(const std::string &data, ws_opcode opcod nullptr, "", [this](std::string const& s1, std::string const& s2) { log(ll_debug, "Dave session constructor callback: " + s1 + ", " + s2); }); - mls_state->dave_session->init(dave::max_protocol_version(), channel_id, creator->me.id.str(), mls_state->mls_key); } - auto key_response = mls_state->dave_session->get_marshalled_key_package(); - key_response.insert(key_response.begin(), voice_client_dave_mls_key_package); - this->write(std::string_view(reinterpret_cast(key_response.data()), key_response.size()), OP_BINARY); + this->reinit_dave_mls_group(); } if (ready_now) { @@ -492,4 +496,133 @@ bool discord_voice_client::handle_frame(const std::string &data, ws_opcode opcod } +/* + * Handle DAVE frame utilities. + */ + +void discord_voice_client::ready_for_transition(const std::string &data) { + log(ll_debug, "Ready to execute transition " + std::to_string(this->mls_state->transition_id)); + json obj = { + { "op", voice_client_dave_transition_ready }, + { + "d", + { + { "transition_id", this->mls_state->transition_id }, + } + } + }; + this->write(obj.dump(-1, ' ', false, json::error_handler_t::replace), OP_TEXT); + this->mls_state->pending_transition.id = this->mls_state->transition_id; + + /* When the included transition ID is 0, the transition is for (re)initialization and it can be executed immediately. */ + if (this->mls_state->transition_id == 0) { + /* Mark state ready and update rachets the first time */ + update_ratchets(); + } + + if (!this->mls_state->done_ready) { + this->mls_state->done_ready = true; + + if (!creator->on_voice_ready.empty()) { + voice_ready_t rdy(nullptr, data); + rdy.voice_client = this; + rdy.voice_channel_id = this->channel_id; + creator->on_voice_ready.call(rdy); + } + } +} + +void discord_voice_client::recover_from_invalid_commit_welcome() { + json obj = { + {"op", voice_client_dave_mls_invalid_commit_welcome}, + { + "d", { + "transition_id", this->mls_state->transition_id + } + } + }; + this->write(obj.dump(-1, ' ', false, json::error_handler_t::replace), OP_TEXT); + this->reinit_dave_mls_group(); +} + +bool discord_voice_client::execute_pending_upgrade_downgrade() { + bool did_upgrade_downgrade = false; + + if (this->mls_state->transition_id != this->mls_state->pending_transition.id) { + log(ll_debug, "execute_pending_upgrade_downgrade unexpected transition_id, we never received voice_client_dave_prepare_transition event with this id: " + std::to_string(this->mls_state->transition_id)); + } else if (dave_version != this->mls_state->pending_transition.protocol_version) { + dave_version = this->mls_state->pending_transition.protocol_version == 1 ? dave_version_1 : dave_version_none; + + if (this->mls_state->pending_transition.protocol_version != 0 && dave_version == dave_version_none) { + log(ll_debug, "execute_pending_upgrade_downgrade unexpected protocol version: " + std::to_string(this->mls_state->pending_transition.protocol_version)+ " in transition " + std::to_string(this->mls_state->transition_id)); + } else { + log(ll_debug, "execute_pending_upgrade_downgrade upgrade/downgrade successful"); + did_upgrade_downgrade = true; + } + } + + this->mls_state->pending_transition.is_pending = false; + return did_upgrade_downgrade; +} + +void discord_voice_client::reinit_dave_mls_group() { + mls_state->dave_session->init(dave::max_protocol_version(), channel_id, creator->me.id.str(), mls_state->mls_key); + + auto key_response = mls_state->dave_session->get_marshalled_key_package(); + key_response.insert(key_response.begin(), voice_client_dave_mls_key_package); + this->write(std::string_view(reinterpret_cast(key_response.data()), key_response.size()), OP_BINARY); + + mls_state->encryptor = std::make_unique(*creator); + mls_state->decryptors.clear(); + + mls_state->cached_roster_map.clear(); + + mls_state->privacy_code.clear(); + + /* Remove any user in pending remove from MLS member list */ + for (const auto &user : dave_mls_pending_remove_list) { + dave_mls_user_list.erase(user); + } + dave_mls_pending_remove_list.clear(); } + +void discord_voice_client::process_mls_group_rosters(const dave::roster_map &rmap) { + log(ll_debug, "process_mls_group_rosters of size: " + std::to_string(rmap.size())); + + for (const auto &[k, v] : rmap) { + bool user_has_key = !v.empty(); + + /* Debug log for changed and added keys */ + auto cached_user = mls_state->cached_roster_map.find(k); + if (cached_user == mls_state->cached_roster_map.end()) { + log(ll_debug, "Added user to MLS Group: " + std::to_string(k)); + } else if (user_has_key && cached_user->second != v) { + log(ll_debug, "Changed user key in MLS Group: " + std::to_string(k)); + } + + /* + * Remove user from recognized list. + * Do not remove user with non-empty key. + */ + if (user_has_key) { + continue; + } + + dpp::snowflake u_id(k); + auto u_id_str = u_id.str(); + + log(ll_debug, "Removed user from MLS Group: " + u_id_str); + + dave_mls_user_list.erase(u_id_str); + dave_mls_pending_remove_list.erase(u_id_str); + + /* Remove this user's key ratchet */ + mls_state->decryptors.erase(u_id); + } + + mls_state->cached_roster_map = rmap; +} + +} + +// vim: sw=4 noet diff --git a/src/dpp/voice/enabled/opus.cpp b/src/dpp/voice/enabled/opus.cpp index e57fcb69f4..2834624b77 100644 --- a/src/dpp/voice/enabled/opus.cpp +++ b/src/dpp/voice/enabled/opus.cpp @@ -106,7 +106,7 @@ discord_voice_client& discord_voice_client::send_audio_opus(uint8_t* opus_packet log(ll_warning, "DAVE Encryption failure: " + std::to_string(result)); } else { encoded_audio = encrypted_buffer; - encoded_audio_length = encoded_audio.size(); + encoded_audio_length = encrypted_buffer.size(); } }