-
-
Notifications
You must be signed in to change notification settings - Fork 127
feat: cstoken (NCT) privacy token fallback + proto update #433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
fc6b868
db309c7
2d86cd8
af01531
6625661
9985b63
b088193
f5cbef6
99c3d72
b078011
95c4daf
8cb0f7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ use crate::client::Client; | |
| use crate::store::signal_adapter::SignalProtocolStoreAdapter; | ||
| use crate::types::message::EditAttribute; | ||
| use anyhow::anyhow; | ||
| use log::debug; | ||
| use wacore::client::context::SendContextResolver; | ||
| use wacore::libsignal::protocol::SignalProtocolError; | ||
| use wacore::types::jid::JidExt; | ||
|
|
@@ -721,6 +722,8 @@ impl Client { | |
| participants: Vec<Jid>, | ||
| } | ||
| let mut skdm_update: Option<SkdmUpdate> = None; | ||
| let mut should_issue_tc_token_after_send = false; | ||
| let tc_issue_target = to.clone(); | ||
|
|
||
| let stanza_to_send: wacore_binary::Node = if peer && !to.is_group() { | ||
| // Peer messages are only valid for individual users, not groups | ||
|
|
@@ -1007,9 +1010,13 @@ impl Client { | |
| // Include tctoken in 1:1 messages (matches WhatsApp Web behavior). | ||
| // Skip for newsletters, groups, and own JID. | ||
| let mut extra_stanza_nodes = extra_stanza_nodes; | ||
| if !to.is_group() && !to.is_newsletter() { | ||
| self.maybe_include_tc_token(&to, &mut extra_stanza_nodes) | ||
| should_issue_tc_token_after_send = !to.is_group() | ||
| && !to.is_newsletter() | ||
| && self | ||
| .maybe_include_tc_token(&to, &mut extra_stanza_nodes) | ||
| .await; | ||
| if should_issue_tc_token_after_send { | ||
| debug!(target: "Client/TcToken", "Scheduled tc token issuance after send for {}", to); | ||
| } | ||
|
|
||
| // Acquire lock only for encryption | ||
|
|
@@ -1049,6 +1056,10 @@ impl Client { | |
|
|
||
| self.send_node(stanza_to_send).await?; | ||
|
|
||
| if should_issue_tc_token_after_send { | ||
| self.issue_tc_token_after_send(&tc_issue_target).await; | ||
| } | ||
|
|
||
| // Update SKDM recipient cache AFTER server ACK (matches WhatsApp Web behavior). | ||
| // WA Web only calls markHasSenderKey() after the server confirms receipt. | ||
| if let Some(update) = skdm_update { | ||
|
|
@@ -1069,13 +1080,17 @@ impl Client { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Look up and include a tctoken in outgoing 1:1 message stanza nodes. | ||
| /// Look up and include a privacy token in outgoing 1:1 message stanza nodes. | ||
| /// | ||
| /// Follows WA Web's fallback chain (MsgCreateFanoutStanza.js): | ||
| /// 1. tctoken — from stored trusted contact token (if valid, non-expired) | ||
| /// 2. cstoken — HMAC-SHA256(nct_salt, recipient_lid) fallback for first-contact | ||
| /// 3. No token — message sent without token (server may return 463) | ||
| /// | ||
| /// If a valid (non-expired) token exists, adds a `<tctoken>` child node. | ||
| /// If the token is missing or expired, attempts to issue new tokens via IQ. | ||
| async fn maybe_include_tc_token(&self, to: &Jid, extra_nodes: &mut Vec<Node>) { | ||
| /// Returns whether we should issue a new tc token for this chat after send. | ||
| async fn maybe_include_tc_token(&self, to: &Jid, extra_nodes: &mut Vec<Node>) -> bool { | ||
| use wacore::iq::tctoken::{ | ||
| IssuePrivacyTokensSpec, build_tc_token_node, is_tc_token_expired, | ||
| build_cs_token_node, build_tc_token_node, compute_cs_token, is_tc_token_expired, | ||
| should_send_new_tc_token, | ||
| }; | ||
| use wacore::store::traits::TcTokenEntry; | ||
|
|
@@ -1091,37 +1106,39 @@ impl Client { | |
| .as_ref() | ||
| .is_some_and(|lid| lid.is_same_user_as(to)); | ||
| if is_self { | ||
| return; | ||
| return false; | ||
| } | ||
|
|
||
| // Resolve the destination to a LID for token lookup | ||
| let token_jid = if to.is_lid() { | ||
| to.user.clone() | ||
| // Resolve the destination to a LID user string once — reused for | ||
| // tctoken lookup, issuance, and cstoken HMAC input. | ||
| // Returns Some(lid_user) if resolved, None if no LID mapping exists. | ||
| let resolved_lid_user = if to.is_lid() { | ||
| Some(to.user.clone()) | ||
| } else { | ||
| match self.lid_pn_cache.get_current_lid(&to.user).await { | ||
| Some(lid) => lid, | ||
| None => to.user.clone(), | ||
| } | ||
| self.lid_pn_cache.get_current_lid(&to.user).await | ||
| }; | ||
| let token_jid = resolved_lid_user.as_deref().unwrap_or(&to.user).to_string(); | ||
|
|
||
| let backend = self.persistence_manager.backend(); | ||
|
|
||
| // Look up existing token | ||
| // Look up existing tctoken | ||
| let existing = match backend.get_tc_token(&token_jid).await { | ||
| Ok(entry) => entry, | ||
| Err(e) => { | ||
| log::warn!(target: "Client/TcToken", "Failed to get tc_token for {}: {e}", token_jid); | ||
| return; | ||
| None | ||
| } | ||
| }; | ||
|
|
||
| let should_issue_after_send = | ||
| should_send_new_tc_token(existing.as_ref().and_then(|entry| entry.sender_timestamp)); | ||
|
Comment on lines
+1155
to
+1156
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| match existing { | ||
| Some(entry) if !is_tc_token_expired(entry.token_timestamp) => { | ||
| // Valid token — include it in the stanza | ||
| // Valid tctoken — include it in the stanza | ||
| extra_nodes.push(build_tc_token_node(&entry.token)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Check if we should re-issue (bucket boundary crossed). | ||
| // Update sender_timestamp to mark we've sent our token in this bucket. | ||
| if should_send_new_tc_token(entry.sender_timestamp) { | ||
| let now = wacore::time::now_secs(); | ||
| let updated_entry = TcTokenEntry { | ||
|
|
@@ -1134,40 +1151,60 @@ impl Client { | |
| } | ||
| } | ||
| _ => { | ||
| // Token missing or expired — try to issue | ||
| let to_lid = self.resolve_to_lid_jid(to).await; | ||
| match self | ||
| .execute(IssuePrivacyTokensSpec::new(std::slice::from_ref(&to_lid))) | ||
| .await | ||
| if let Some(salt) = &snapshot.nct_salt | ||
| && let Some(lid_user) = &resolved_lid_user | ||
| { | ||
| Ok(response) => { | ||
| let now = wacore::time::now_secs(); | ||
| for received in &response.tokens { | ||
| let entry = TcTokenEntry { | ||
| token: received.token.clone(), | ||
| token_timestamp: received.timestamp, | ||
| sender_timestamp: Some(now), | ||
| }; | ||
|
|
||
| // Store the received token | ||
| let store_jid = received.jid.user.clone(); | ||
| if let Err(e) = backend.put_tc_token(&store_jid, &entry).await { | ||
| log::warn!(target: "Client/TcToken", "Failed to store issued tc_token: {e}"); | ||
| } | ||
|
|
||
| // Include in message stanza | ||
| if !received.token.is_empty() { | ||
| extra_nodes.push(build_tc_token_node(&received.token)); | ||
| } | ||
| } | ||
| } | ||
| Err(e) => { | ||
| log::debug!(target: "Client/TcToken", "Failed to issue tc_token for {}: {e}", to_lid); | ||
| // Don't fail the message send — tctoken is optional | ||
| } | ||
| // HMAC input is "user@lid" (account LID without device suffix), | ||
| // matching WA Web's accountLid.toString() | ||
| let recipient_lid = wacore_binary::jid::Jid::new(lid_user, "lid").to_string(); | ||
| let cs_token = compute_cs_token(salt, &recipient_lid); | ||
| extra_nodes.push(build_cs_token_node(&cs_token)); | ||
| log::debug!(target: "Client/CsToken", "Attached cstoken for {} (NCT fallback)", to); | ||
| } else { | ||
| log::debug!(target: "Client/CsToken", "No tctoken or NCT salt/LID available for {}", to); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| should_issue_after_send | ||
| } | ||
|
|
||
| async fn issue_tc_token_after_send(&self, to: &Jid) { | ||
| use wacore::iq::tctoken::IssuePrivacyTokensSpec; | ||
|
|
||
| let to_lid = self.resolve_to_lid_jid(to).await; | ||
| let Ok(response) = self | ||
| .execute(IssuePrivacyTokensSpec::new(std::slice::from_ref(&to_lid))) | ||
| .await | ||
| else { | ||
| log::debug!(target: "Client/TcToken", "Failed to issue tc_token for {}", to_lid); | ||
| return; | ||
| }; | ||
|
|
||
| self.store_issued_tc_tokens(&response.tokens).await; | ||
| } | ||
|
|
||
| async fn store_issued_tc_tokens(&self, tokens: &[wacore::iq::tctoken::ReceivedTcToken]) { | ||
| use wacore::store::traits::TcTokenEntry; | ||
|
|
||
| if tokens.is_empty() { | ||
| return; | ||
| } | ||
|
|
||
| let backend = self.persistence_manager.backend(); | ||
| let now = wacore::time::now_secs(); | ||
| for received in tokens { | ||
| let entry = TcTokenEntry { | ||
| token: received.token.clone(), | ||
| token_timestamp: received.timestamp, | ||
| sender_timestamp: Some(now), | ||
| }; | ||
|
|
||
| let store_jid = received.jid.user.clone(); | ||
| if let Err(e) = backend.put_tc_token(&store_jid, &entry).await { | ||
| log::warn!(target: "Client/TcToken", "Failed to store issued tc_token: {e}"); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Look up a valid (non-expired) tctoken for a JID. Returns the raw token bytes if found. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cancel pending sent-node waiters on disconnect.
These waiters are transport-scoped, but
cleanup_connection_state()never drops them. If the socket dies before a match arrives, the receiver can stay pending across reconnects and then resolve against a later stanza from the wrong connection.Suggested cleanup
async fn cleanup_connection_state(&self) { + { + let mut waiters = self + .sent_node_waiters + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !waiters.is_empty() { + waiters.clear(); // dropping senders cancels receivers + self.sent_node_waiter_count.store(0, Ordering::Release); + } + } + self.is_logged_in.store(false, Ordering::Relaxed); self.is_ready.store(false, Ordering::Relaxed);Also applies to: 593-594, 2994-3011, 3037-3055, 3208-3210
🤖 Prompt for AI Agents