diff --git a/src/appstate_sync.rs b/src/appstate_sync.rs index 508d8cd57..790f5f960 100644 --- a/src/appstate_sync.rs +++ b/src/appstate_sync.rs @@ -505,6 +505,28 @@ mod tests { async fn consume_forget_marks(&self, _: &str) -> StoreResult> { Ok(vec![]) } + async fn get_tc_token( + &self, + _: &str, + ) -> StoreResult> { + Ok(None) + } + async fn put_tc_token( + &self, + _: &str, + _: &wacore::store::traits::TcTokenEntry, + ) -> StoreResult<()> { + Ok(()) + } + async fn delete_tc_token(&self, _: &str) -> StoreResult<()> { + Ok(()) + } + async fn get_all_tc_token_jids(&self) -> StoreResult> { + Ok(vec![]) + } + async fn delete_expired_tc_tokens(&self, _: i64) -> StoreResult { + Ok(0) + } } // Implement DeviceStore - Device persistence diff --git a/src/client.rs b/src/client.rs index ac21d6899..710ccf9fa 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1234,6 +1234,11 @@ impl Client { if let Err(e) = r_digest { warn!("Background init: Failed to send digest: {e:?}"); } + + // Prune expired tcTokens on connect (matches WhatsApp Web's PrivacyTokenJob) + if let Err(e) = bg_client.tc_token().prune_expired().await { + warn!("Background init: Failed to prune expired tc_tokens: {e:?}"); + } }); client_clone diff --git a/src/features/contacts.rs b/src/features/contacts.rs index e40ed0930..9cba70d6f 100644 --- a/src/features/contacts.rs +++ b/src/features/contacts.rs @@ -9,7 +9,7 @@ use log::debug; use std::collections::HashMap; use wacore::iq::contacts::{ProfilePictureSpec, ProfilePictureType}; use wacore::iq::usync::{ContactInfoSpec, IsOnWhatsAppSpec, UserInfoSpec}; -use wacore_binary::jid::Jid; +use wacore_binary::jid::{Jid, JidExt}; // Re-export types from wacore pub use wacore::iq::contacts::ProfilePicture; @@ -68,7 +68,15 @@ impl<'a> Contacts<'a> { } else { ProfilePictureType::Full }; - let spec = ProfilePictureSpec::new(jid, picture_type); + let mut spec = ProfilePictureSpec::new(jid, picture_type); + + // Include tctoken for user JIDs (skip groups, newsletters) + if !jid.is_group() + && !jid.is_newsletter() + && let Some(token) = self.client.lookup_tc_token_for_jid(jid).await + { + spec = spec.with_tc_token(token); + } Ok(self.client.execute(spec).await?) } diff --git a/src/features/mod.rs b/src/features/mod.rs index 7eb9371c4..5e980247e 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -4,6 +4,7 @@ mod contacts; mod groups; mod mex; mod presence; +mod tctoken; pub use blocking::{Blocking, BlocklistEntry}; @@ -20,3 +21,5 @@ pub use groups::{ pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; pub use presence::{Presence, PresenceStatus}; + +pub use tctoken::TcToken; diff --git a/src/features/presence.rs b/src/features/presence.rs index 6b0080e8b..605f256d2 100644 --- a/src/features/presence.rs +++ b/src/features/presence.rs @@ -1,7 +1,9 @@ use crate::client::Client; use log::{debug, warn}; use wacore::StringEnum; +use wacore::iq::tctoken::build_tc_token_node; use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::Jid; /// Presence status for online/offline state. #[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)] @@ -83,6 +85,33 @@ impl<'a> Presence<'a> { pub async fn set_unavailable(&self) -> Result<(), anyhow::Error> { self.set(PresenceStatus::Unavailable).await } + + /// Subscribe to a contact's presence updates. + /// + /// Sends a `` stanza to the target JID. + /// If a valid tctoken exists for the contact, it is included as a child node. + /// + /// ## Wire Format + /// ```xml + /// + /// + /// + /// ``` + pub async fn subscribe(&self, jid: &Jid) -> Result<(), anyhow::Error> { + debug!("presence subscribe: subscribing to {}", jid); + + let mut builder = NodeBuilder::new("presence") + .attr("type", "subscribe") + .attr("to", jid.to_string()); + + // Include tctoken if available (no t attribute, matching WhatsApp Web) + if let Some(token) = self.client.lookup_tc_token_for_jid(jid).await { + builder = builder.children([build_tc_token_node(&token)]); + } + + let node = builder.build(); + self.client.send_node(node).await.map_err(|e| e.into()) + } } impl Client { diff --git a/src/features/tctoken.rs b/src/features/tctoken.rs new file mode 100644 index 000000000..e7bc03cda --- /dev/null +++ b/src/features/tctoken.rs @@ -0,0 +1,97 @@ +//! Trusted contact privacy token feature. +//! +//! Provides high-level APIs for managing tcTokens, matching WhatsApp Web's +//! `WAWebTrustedContactsUtils` and `WAWebPrivacyTokenJob`. +//! +//! ## Usage +//! ```ignore +//! // Issue tokens to contacts +//! let tokens = client.tc_token().issue_tokens(&[jid]).await?; +//! +//! // Prune expired tokens +//! let count = client.tc_token().prune_expired().await?; +//! ``` + +use crate::client::Client; +use crate::request::IqError; +use wacore::iq::tctoken::{IssuePrivacyTokensSpec, ReceivedTcToken, tc_token_expiration_cutoff}; +use wacore::store::traits::TcTokenEntry; +use wacore_binary::jid::Jid; + +/// Feature handle for trusted contact token operations. +pub struct TcToken<'a> { + client: &'a Client, +} + +impl<'a> TcToken<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Issue privacy tokens for the given contacts. + /// + /// Sends an IQ to the server requesting tokens for the specified JIDs (should be LID JIDs). + /// Stores the received tokens and returns them. + pub async fn issue_tokens(&self, jids: &[Jid]) -> Result, IqError> { + if jids.is_empty() { + return Ok(Vec::new()); + } + + let spec = IssuePrivacyTokensSpec::new(jids); + let response = self.client.execute(spec).await?; + let backend = self.client.persistence_manager.backend(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + for received in &response.tokens { + let entry = TcTokenEntry { + token: received.token.clone(), + token_timestamp: received.timestamp, + sender_timestamp: Some(now), + }; + + if let Err(e) = backend.put_tc_token(&received.jid.user, &entry).await { + log::warn!(target: "Client/TcToken", "Failed to store issued tc_token for {}: {e}", received.jid); + } + } + + Ok(response.tokens) + } + + /// Prune expired tc tokens from the store. + /// + /// Deletes all tokens older than the rolling window (28 days by default). + /// Returns the number of tokens deleted. + pub async fn prune_expired(&self) -> Result { + let backend = self.client.persistence_manager.backend(); + let cutoff = tc_token_expiration_cutoff(); + let deleted = backend.delete_expired_tc_tokens(cutoff).await?; + + if deleted > 0 { + log::info!(target: "Client/TcToken", "Pruned {} expired tc_tokens", deleted); + } + + Ok(deleted) + } + + /// Get a stored tc token for a JID. + pub async fn get(&self, jid: &str) -> Result, anyhow::Error> { + let backend = self.client.persistence_manager.backend(); + Ok(backend.get_tc_token(jid).await?) + } + + /// Get all JIDs that have stored tc tokens. + pub async fn get_all_jids(&self) -> Result, anyhow::Error> { + let backend = self.client.persistence_manager.backend(); + Ok(backend.get_all_tc_token_jids().await?) + } +} + +impl Client { + /// Access trusted contact token operations. + pub fn tc_token(&self) -> TcToken<'_> { + TcToken::new(self) + } +} diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index 17937aa6d..43dc068aa 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -99,6 +99,11 @@ async fn handle_notification_impl(client: &Arc, node: &Node) { // Notifies about business account status changes: verified name, profile, removal handle_business_notification(client, node).await; } + "privacy_token" => { + // Handle incoming trusted contact privacy token notifications. + // Matches WhatsApp Web's WAWebHandlePrivacyTokenNotification. + handle_privacy_token_notification(client, node).await; + } _ => { warn!("TODO: Implement handler for "); client @@ -307,6 +312,124 @@ async fn handle_account_sync_devices(client: &Arc, node: &Node, devices_ } } +/// Handle incoming privacy_token notification. +/// +/// Stores trusted contact tokens from contacts. Matches WhatsApp Web's +/// `WAWebHandlePrivacyTokenNotification`. +/// +/// Structure: +/// ```xml +/// +/// +/// +/// +/// +/// ``` +async fn handle_privacy_token_notification(client: &Arc, node: &Node) { + use wacore::iq::tctoken::parse_privacy_token_notification; + use wacore::store::traits::TcTokenEntry; + + // Resolve the sender to a LID JID for storage. + // WA Web uses `sender_lid` attr if present, otherwise resolves from `from`. + let sender_lid = node + .attrs() + .optional_jid("sender_lid") + .map(|j| j.user.clone()); + + let sender_lid = match sender_lid { + Some(lid) if !lid.is_empty() => lid, + _ => { + // Fall back to resolving from the `from` JID via LID-PN cache + let from_jid = match node.attrs().optional_jid("from") { + Some(jid) => jid, + None => { + warn!(target: "Client/TcToken", "privacy_token notification missing 'from' attribute"); + return; + } + }; + + if from_jid.is_lid() { + from_jid.user.clone() + } else { + // Try to resolve phone number to LID + match client.lid_pn_cache.get_current_lid(&from_jid.user).await { + Some(lid) => lid, + None => { + debug!( + target: "Client/TcToken", + "Cannot resolve LID for privacy_token sender {}, storing under PN", + from_jid + ); + from_jid.user.clone() + } + } + } + } + }; + + // Parse the token data from the notification + let received_tokens = match parse_privacy_token_notification(node) { + Ok(tokens) => tokens, + Err(e) => { + warn!(target: "Client/TcToken", "Failed to parse privacy_token notification: {e}"); + return; + } + }; + + if received_tokens.is_empty() { + debug!(target: "Client/TcToken", "privacy_token notification had no trusted_contact tokens"); + return; + } + + let backend = client.persistence_manager.backend(); + + for received in &received_tokens { + match backend.get_tc_token(&sender_lid).await { + Ok(Some(existing)) => { + // Timestamp monotonicity guard: only store if incoming >= existing + if received.timestamp < existing.token_timestamp { + debug!( + target: "Client/TcToken", + "Skipping older token for {} (incoming={}, existing={})", + sender_lid, received.timestamp, existing.token_timestamp + ); + continue; + } + + // Preserve existing sender_timestamp when updating token + let entry = TcTokenEntry { + token: received.token.clone(), + token_timestamp: received.timestamp, + sender_timestamp: existing.sender_timestamp, + }; + + if let Err(e) = backend.put_tc_token(&sender_lid, &entry).await { + warn!(target: "Client/TcToken", "Failed to update tc_token for {}: {e}", sender_lid); + } else { + debug!(target: "Client/TcToken", "Updated tc_token for {} (t={})", sender_lid, received.timestamp); + } + } + Ok(None) => { + // New token — no existing entry + let entry = TcTokenEntry { + token: received.token.clone(), + token_timestamp: received.timestamp, + sender_timestamp: None, + }; + + if let Err(e) = backend.put_tc_token(&sender_lid, &entry).await { + warn!(target: "Client/TcToken", "Failed to store tc_token for {}: {e}", sender_lid); + } else { + debug!(target: "Client/TcToken", "Stored new tc_token for {} (t={})", sender_lid, received.timestamp); + } + } + Err(e) => { + warn!(target: "Client/TcToken", "Failed to read tc_token for {}: {e}, skipping", sender_lid); + } + } + } +} + /// Handle business notification (WhatsApp Web: `WAWebHandleBusinessNotification`). async fn handle_business_notification(client: &Arc, node: &Node) { let notification = match BusinessNotification::try_parse(node) { diff --git a/src/lib.rs b/src/lib.rs index 1549f1993..2cca7006d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub use features::{ GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupSubject, Groups, IsOnWhatsAppResult, MemberAddMode, MemberLinkMode, MembershipApprovalMode, Mex, MexError, MexErrorExtensions, MexRequest, MexResponse, - ParticipantChangeResponse, Presence, PresenceStatus, ProfilePicture, UserInfo, + ParticipantChangeResponse, Presence, PresenceStatus, ProfilePicture, TcToken, UserInfo, }; pub mod bot; diff --git a/src/send.rs b/src/send.rs index fb6f4f7b1..171291965 100644 --- a/src/send.rs +++ b/src/send.rs @@ -446,6 +446,14 @@ impl Client { .ok_or_else(|| anyhow!("Not logged in"))?; let account_info = device_snapshot.account.clone(); + // 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) + .await; + } + // Acquire lock only for encryption let session_mutex = self .session_locks @@ -482,6 +490,152 @@ impl Client { self.send_node(stanza_to_send).await.map_err(|e| e.into()) } + + /// Look up and include a tctoken in outgoing 1:1 message stanza nodes. + /// + /// If a valid (non-expired) token exists, adds a `` 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) { + use wacore::iq::tctoken::{ + IssuePrivacyTokensSpec, build_tc_token_node, is_tc_token_expired, + should_send_new_tc_token, + }; + use wacore::store::traits::TcTokenEntry; + + // Skip for own JID — no need to send privacy token to ourselves + let snapshot = self.persistence_manager.get_device_snapshot().await; + let is_self = snapshot + .pn + .as_ref() + .is_some_and(|pn| pn.is_same_user_as(to)) + || snapshot + .lid + .as_ref() + .is_some_and(|lid| lid.is_same_user_as(to)); + if is_self { + return; + } + + // Resolve the destination to a LID for token lookup + let token_jid = if to.is_lid() { + to.user.clone() + } else { + match self.lid_pn_cache.get_current_lid(&to.user).await { + Some(lid) => lid, + None => to.user.clone(), + } + }; + + let backend = self.persistence_manager.backend(); + + // Look up existing token + 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; + } + }; + + match existing { + Some(entry) if !is_tc_token_expired(entry.token_timestamp) => { + // Valid token — include it in the stanza + extra_nodes.push(build_tc_token_node(&entry.token)); + + // 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 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let updated_entry = TcTokenEntry { + sender_timestamp: Some(now), + ..entry + }; + if let Err(e) = backend.put_tc_token(&token_jid, &updated_entry).await { + log::warn!(target: "Client/TcToken", "Failed to update sender_timestamp: {e}"); + } + } + } + _ => { + // 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 + { + Ok(response) => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + 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 + } + } + } + } + } + + /// Look up a valid (non-expired) tctoken for a JID. Returns the raw token bytes if found. + /// + /// Used by profile picture, presence subscribe, and other features that need tctoken gating. + pub(crate) async fn lookup_tc_token_for_jid(&self, jid: &Jid) -> Option> { + use wacore::iq::tctoken::is_tc_token_expired; + + let token_jid = if jid.is_lid() { + jid.user.clone() + } else { + match self.lid_pn_cache.get_current_lid(&jid.user).await { + Some(lid) => lid, + None => jid.user.clone(), + } + }; + + let backend = self.persistence_manager.backend(); + match backend.get_tc_token(&token_jid).await { + Ok(Some(entry)) if !is_tc_token_expired(entry.token_timestamp) => Some(entry.token), + Ok(_) => None, + Err(e) => { + log::warn!(target: "Client/TcToken", "Failed to get tc_token for {}: {e}", token_jid); + None + } + } + } + + /// Resolve a JID to its LID form for tc_token storage. + async fn resolve_to_lid_jid(&self, jid: &Jid) -> Jid { + if jid.is_lid() { + return jid.clone(); + } + + if let Some(lid_user) = self.lid_pn_cache.get_current_lid(&jid.user).await { + Jid::new(&lid_user, "lid") + } else { + jid.clone() + } + } } #[cfg(test)] diff --git a/storages/sqlite-storage/migrations/2026-02-12-000000_add_tc_tokens/down.sql b/storages/sqlite-storage/migrations/2026-02-12-000000_add_tc_tokens/down.sql new file mode 100644 index 000000000..634acff08 --- /dev/null +++ b/storages/sqlite-storage/migrations/2026-02-12-000000_add_tc_tokens/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tc_tokens; diff --git a/storages/sqlite-storage/migrations/2026-02-12-000000_add_tc_tokens/up.sql b/storages/sqlite-storage/migrations/2026-02-12-000000_add_tc_tokens/up.sql new file mode 100644 index 000000000..21d67b0a7 --- /dev/null +++ b/storages/sqlite-storage/migrations/2026-02-12-000000_add_tc_tokens/up.sql @@ -0,0 +1,15 @@ +-- Trusted Contact (tcToken) storage. +-- Stores privacy tokens per contact for 1:1 messaging trust verification. +-- Matches WhatsApp Web's Chat.tcToken / tcTokenTimestamp / tcTokenSenderTimestamp fields. + +CREATE TABLE tc_tokens ( + jid TEXT NOT NULL, + token BLOB NOT NULL, + token_timestamp INTEGER NOT NULL, + sender_timestamp INTEGER, + device_id INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (jid, device_id) +); + +CREATE INDEX idx_tc_tokens_timestamp ON tc_tokens (token_timestamp, device_id); diff --git a/storages/sqlite-storage/src/schema.rs b/storages/sqlite-storage/src/schema.rs index 8c707f450..ea2120f09 100644 --- a/storages/sqlite-storage/src/schema.rs +++ b/storages/sqlite-storage/src/schema.rs @@ -140,6 +140,17 @@ diesel::table! { } } +diesel::table! { + tc_tokens (jid, device_id) { + jid -> Text, + token -> Binary, + token_timestamp -> BigInt, + sender_timestamp -> Nullable, + device_id -> Integer, + updated_at -> BigInt, + } +} + diesel::allow_tables_to_appear_in_same_query!( app_state_keys, app_state_mutation_macs, @@ -155,4 +166,5 @@ diesel::allow_tables_to_appear_in_same_query!( sessions, signed_prekeys, skdm_recipients, + tc_tokens, ); diff --git a/storages/sqlite-storage/src/sqlite_store.rs b/storages/sqlite-storage/src/sqlite_store.rs index 6a41e3eb0..c285b5f05 100644 --- a/storages/sqlite-storage/src/sqlite_store.rs +++ b/storages/sqlite-storage/src/sqlite_store.rs @@ -1790,6 +1790,136 @@ impl ProtocolStore for SqliteStore { .await .map_err(|e| StoreError::Database(e.to_string()))? } + + async fn get_tc_token(&self, jid: &str) -> Result> { + let pool = self.pool.clone(); + let device_id = self.device_id; + let jid = jid.to_string(); + tokio::task::spawn_blocking(move || -> Result> { + let mut conn = pool + .get() + .map_err(|e| StoreError::Connection(e.to_string()))?; + let row: Option<(Vec, i64, Option)> = tc_tokens::table + .select(( + tc_tokens::token, + tc_tokens::token_timestamp, + tc_tokens::sender_timestamp, + )) + .filter(tc_tokens::jid.eq(&jid)) + .filter(tc_tokens::device_id.eq(device_id)) + .first(&mut conn) + .optional() + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok( + row.map(|(token, token_timestamp, sender_timestamp)| TcTokenEntry { + token, + token_timestamp, + sender_timestamp, + }), + ) + }) + .await + .map_err(|e| StoreError::Database(e.to_string()))? + } + + async fn put_tc_token(&self, jid: &str, entry: &TcTokenEntry) -> Result<()> { + let pool = self.pool.clone(); + let device_id = self.device_id; + let jid = jid.to_string(); + let entry = entry.clone(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|e| StoreError::Connection(e.to_string()))?; + diesel::insert_into(tc_tokens::table) + .values(( + tc_tokens::jid.eq(&jid), + tc_tokens::token.eq(&entry.token), + tc_tokens::token_timestamp.eq(entry.token_timestamp), + tc_tokens::sender_timestamp.eq(entry.sender_timestamp), + tc_tokens::device_id.eq(device_id), + tc_tokens::updated_at.eq(now), + )) + .on_conflict((tc_tokens::jid, tc_tokens::device_id)) + .do_update() + .set(( + tc_tokens::token.eq(&entry.token), + tc_tokens::token_timestamp.eq(entry.token_timestamp), + tc_tokens::sender_timestamp.eq(entry.sender_timestamp), + tc_tokens::updated_at.eq(now), + )) + .execute(&mut conn) + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok(()) + }) + .await + .map_err(|e| StoreError::Database(e.to_string()))??; + Ok(()) + } + + async fn delete_tc_token(&self, jid: &str) -> Result<()> { + let pool = self.pool.clone(); + let device_id = self.device_id; + let jid = jid.to_string(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|e| StoreError::Connection(e.to_string()))?; + diesel::delete( + tc_tokens::table + .filter(tc_tokens::jid.eq(&jid)) + .filter(tc_tokens::device_id.eq(device_id)), + ) + .execute(&mut conn) + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok(()) + }) + .await + .map_err(|e| StoreError::Database(e.to_string()))??; + Ok(()) + } + + async fn get_all_tc_token_jids(&self) -> Result> { + let pool = self.pool.clone(); + let device_id = self.device_id; + tokio::task::spawn_blocking(move || -> Result> { + let mut conn = pool + .get() + .map_err(|e| StoreError::Connection(e.to_string()))?; + let jids: Vec = tc_tokens::table + .select(tc_tokens::jid) + .filter(tc_tokens::device_id.eq(device_id)) + .load(&mut conn) + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok(jids) + }) + .await + .map_err(|e| StoreError::Database(e.to_string()))? + } + + async fn delete_expired_tc_tokens(&self, cutoff_timestamp: i64) -> Result { + let pool = self.pool.clone(); + let device_id = self.device_id; + tokio::task::spawn_blocking(move || -> Result { + let mut conn = pool + .get() + .map_err(|e| StoreError::Connection(e.to_string()))?; + let deleted = diesel::delete( + tc_tokens::table + .filter(tc_tokens::token_timestamp.lt(cutoff_timestamp)) + .filter(tc_tokens::device_id.eq(device_id)), + ) + .execute(&mut conn) + .map_err(|e| StoreError::Database(e.to_string()))?; + Ok(deleted as u32) + }) + .await + .map_err(|e| StoreError::Database(e.to_string()))? + } } #[async_trait] @@ -2104,6 +2234,121 @@ mod tests { assert!(consumed.is_empty()); } + #[tokio::test] + async fn test_tc_token_put_and_get() { + let store = create_test_store().await; + + let entry = TcTokenEntry { + token: vec![1, 2, 3, 4, 5], + token_timestamp: 1707000000, + sender_timestamp: Some(1707000100), + }; + + store + .put_tc_token("user@lid", &entry) + .await + .expect("put failed"); + + let loaded = store + .get_tc_token("user@lid") + .await + .expect("get failed") + .expect("should exist"); + + assert_eq!(loaded.token, vec![1, 2, 3, 4, 5]); + assert_eq!(loaded.token_timestamp, 1707000000); + assert_eq!(loaded.sender_timestamp, Some(1707000100)); + } + + #[tokio::test] + async fn test_tc_token_upsert() { + let store = create_test_store().await; + + let entry1 = TcTokenEntry { + token: vec![1, 2, 3], + token_timestamp: 1000, + sender_timestamp: None, + }; + store.put_tc_token("user@lid", &entry1).await.unwrap(); + + let entry2 = TcTokenEntry { + token: vec![4, 5, 6], + token_timestamp: 2000, + sender_timestamp: Some(1500), + }; + store.put_tc_token("user@lid", &entry2).await.unwrap(); + + let loaded = store.get_tc_token("user@lid").await.unwrap().unwrap(); + assert_eq!(loaded.token, vec![4, 5, 6]); + assert_eq!(loaded.token_timestamp, 2000); + assert_eq!(loaded.sender_timestamp, Some(1500)); + } + + #[tokio::test] + async fn test_tc_token_delete() { + let store = create_test_store().await; + + let entry = TcTokenEntry { + token: vec![1, 2, 3], + token_timestamp: 1000, + sender_timestamp: None, + }; + store.put_tc_token("user@lid", &entry).await.unwrap(); + store.delete_tc_token("user@lid").await.unwrap(); + + let result = store.get_tc_token("user@lid").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_tc_token_get_all_jids() { + let store = create_test_store().await; + + let entry = TcTokenEntry { + token: vec![1], + token_timestamp: 1000, + sender_timestamp: None, + }; + store.put_tc_token("user1@lid", &entry).await.unwrap(); + store.put_tc_token("user2@lid", &entry).await.unwrap(); + store.put_tc_token("user3@lid", &entry).await.unwrap(); + + let mut jids = store.get_all_tc_token_jids().await.unwrap(); + jids.sort(); + assert_eq!(jids, vec!["user1@lid", "user2@lid", "user3@lid"]); + } + + #[tokio::test] + async fn test_tc_token_delete_expired() { + let store = create_test_store().await; + + let old = TcTokenEntry { + token: vec![1], + token_timestamp: 1000, + sender_timestamp: None, + }; + let recent = TcTokenEntry { + token: vec![2], + token_timestamp: 5000, + sender_timestamp: None, + }; + store.put_tc_token("old@lid", &old).await.unwrap(); + store.put_tc_token("recent@lid", &recent).await.unwrap(); + + let deleted = store.delete_expired_tc_tokens(3000).await.unwrap(); + assert_eq!(deleted, 1); + + assert!(store.get_tc_token("old@lid").await.unwrap().is_none()); + assert!(store.get_tc_token("recent@lid").await.unwrap().is_some()); + } + + #[tokio::test] + async fn test_tc_token_get_nonexistent() { + let store = create_test_store().await; + let result = store.get_tc_token("nonexistent@lid").await.unwrap(); + assert!(result.is_none()); + } + #[tokio::test] async fn test_sender_key_status_different_groups() { let store = create_test_store().await; diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs index cdac4e94f..037b4fc9f 100644 --- a/wacore/src/iq/contacts.rs +++ b/wacore/src/iq/contacts.rs @@ -2,9 +2,11 @@ //! //! ## Profile Picture Wire Format //! ```xml -//! +//! //! -//! +//! +//! +//! //! //! //! @@ -21,6 +23,7 @@ //! ``` use crate::iq::spec::IqSpec; +use crate::iq::tctoken::build_tc_token_node; use crate::request::InfoQuery; use anyhow::anyhow; use wacore_binary::builder::NodeBuilder; @@ -57,6 +60,8 @@ impl ProfilePictureType { pub struct ProfilePictureSpec { pub jid: Jid, pub picture_type: ProfilePictureType, + /// Optional tctoken to include in the IQ for privacy gating. + pub tc_token: Option>, } impl ProfilePictureSpec { @@ -64,6 +69,7 @@ impl ProfilePictureSpec { Self { jid: jid.clone(), picture_type: ProfilePictureType::Preview, + tc_token: None, } } @@ -71,6 +77,7 @@ impl ProfilePictureSpec { Self { jid: jid.clone(), picture_type: ProfilePictureType::Full, + tc_token: None, } } @@ -78,23 +85,34 @@ impl ProfilePictureSpec { Self { jid: jid.clone(), picture_type, + tc_token: None, } } + + /// Include a tctoken in the profile picture IQ for privacy gating. + pub fn with_tc_token(mut self, token: Vec) -> Self { + self.tc_token = Some(token); + self + } } impl IqSpec for ProfilePictureSpec { type Response = Option; fn build_iq(&self) -> InfoQuery<'static> { - let picture_node = NodeBuilder::new("picture") + let mut picture_builder = NodeBuilder::new("picture") .attr("type", self.picture_type.as_str()) - .attr("query", "url") - .build(); + .attr("query", "url"); + + // tctoken is a child of , matching WhatsApp Web's mixin merge pattern + if let Some(token) = &self.tc_token { + picture_builder = picture_builder.children([build_tc_token_node(token)]); + } InfoQuery::get( "w:profile:picture", Jid::new("", SERVER_JID), - Some(NodeContent::Nodes(vec![picture_node])), + Some(NodeContent::Nodes(vec![picture_builder.build()])), ) .with_target_ref(&self.jid) } @@ -235,4 +253,46 @@ mod tests { let result = spec.parse_response(&response).unwrap(); assert!(result.is_none()); } + + #[test] + fn test_profile_picture_spec_with_tc_token() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::preview(&jid).with_tc_token(vec![0xCA, 0xFE, 0xBA, 0xBE]); + + let iq = spec.build_iq(); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1, "IQ should have one child: picture"); + let picture = &nodes[0]; + assert_eq!(picture.tag, "picture"); + + // tctoken is a child of picture (matching WhatsApp Web's mixin merge) + let tctoken_children: Vec<_> = picture.get_children_by_tag("tctoken").collect(); + assert_eq!(tctoken_children.len(), 1); + match &tctoken_children[0].content { + Some(NodeContent::Bytes(data)) => { + assert_eq!(data, &[0xCA, 0xFE, 0xBA, 0xBE]); + } + _ => panic!("Expected binary content in tctoken node"), + } + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_profile_picture_spec_without_tc_token() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = ProfilePictureSpec::preview(&jid); + + let iq = spec.build_iq(); + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1, "IQ should have one child: picture"); + let picture = &nodes[0]; + assert_eq!(picture.tag, "picture"); + let tctoken_children: Vec<_> = picture.get_children_by_tag("tctoken").collect(); + assert_eq!(tctoken_children.len(), 0, "No tctoken without token"); + } else { + panic!("Expected NodeContent::Nodes"); + } + } } diff --git a/wacore/src/iq/mod.rs b/wacore/src/iq/mod.rs index 041ea7021..e146f99ad 100644 --- a/wacore/src/iq/mod.rs +++ b/wacore/src/iq/mod.rs @@ -13,4 +13,5 @@ pub mod privacy; pub mod props; pub mod spam_report; pub mod spec; +pub mod tctoken; pub mod usync; diff --git a/wacore/src/iq/tctoken.rs b/wacore/src/iq/tctoken.rs new file mode 100644 index 000000000..2eb643d2b --- /dev/null +++ b/wacore/src/iq/tctoken.rs @@ -0,0 +1,502 @@ +//! Trusted Contact (tcToken) privacy token lifecycle. +//! +//! Implements the complete tcToken lifecycle matching WhatsApp Web behavior +//! (WAWebTrustedContactsUtils / WAWebPrivacyTokenJob). +//! +//! ## Wire Formats +//! +//! ### Issue Privacy Tokens (IQ set) +//! ```xml +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! ``` +//! +//! ### Incoming Token Notification +//! ```xml +//! +//! +//! +//! +//! +//! ``` +//! +//! ### Message Stanza +//! ```xml +//! +//! ``` + +use crate::iq::node::{optional_attr, required_attr, required_child}; +use crate::iq::spec::IqSpec; +use crate::request::InfoQuery; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::{Jid, SERVER_JID}; +use wacore_binary::node::{Node, NodeContent}; + +/// IQ namespace for privacy tokens (shared with privacy settings). +pub const PRIVACY_NAMESPACE: &str = "privacy"; + +/// 7 days in seconds — matches WA Web AB prop `tctoken_duration`. +pub const TC_TOKEN_BUCKET_DURATION: i64 = 604_800; + +/// Number of buckets in the rolling window — matches WA Web `tctoken_num_buckets`. +pub const TC_TOKEN_NUM_BUCKETS: i64 = 4; + +/// Total rolling window duration in seconds (bucket_duration * num_buckets). +pub const TC_TOKEN_TOTAL_DURATION: i64 = TC_TOKEN_BUCKET_DURATION * TC_TOKEN_NUM_BUCKETS; + +/// Get the current unix timestamp in seconds. +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +/// Check if a tcToken has expired (older than the rolling window). +pub fn is_tc_token_expired(token_timestamp: i64) -> bool { + is_tc_token_expired_at(token_timestamp, unix_now()) +} + +fn is_tc_token_expired_at(token_timestamp: i64, now: i64) -> bool { + now - token_timestamp >= TC_TOKEN_TOTAL_DURATION +} + +/// Compute the bucket index for a given timestamp. +fn bucket_index(timestamp: i64) -> i64 { + timestamp / TC_TOKEN_BUCKET_DURATION +} + +/// Check if we should issue a new tcToken to a contact. +/// +/// Returns true if: +/// - We have never issued a token (`sender_timestamp` is None) +/// - The current bucket is ahead of the sender_timestamp bucket +/// (meaning a bucket boundary has been crossed) +pub fn should_send_new_tc_token(sender_timestamp: Option) -> bool { + should_send_new_tc_token_at(sender_timestamp, unix_now()) +} + +fn should_send_new_tc_token_at(sender_timestamp: Option, now: i64) -> bool { + match sender_timestamp { + None => true, + Some(ts) => bucket_index(now) > bucket_index(ts), + } +} + +/// Compute the expiration cutoff timestamp for pruning. +/// Tokens with `token_timestamp < cutoff` should be deleted. +pub fn tc_token_expiration_cutoff() -> i64 { + unix_now() - TC_TOKEN_TOTAL_DURATION +} + +/// A token received from the server in an IQ response or notification. +#[derive(Debug, Clone)] +pub struct ReceivedTcToken { + /// The JID this token belongs to. + pub jid: Jid, + /// Raw token bytes. + pub token: Vec, + /// Timestamp from the `t` attribute. + pub timestamp: i64, +} + +/// Token data parsed from a notification (JID resolved by caller). +#[derive(Debug, Clone)] +pub struct ParsedTokenData { + /// Raw token bytes. + pub token: Vec, + /// Timestamp from the `t` attribute. + pub timestamp: i64, +} + +/// Issues privacy tokens to one or more contacts. +/// +/// Sends our token to the specified JIDs and receives their tokens back. +pub struct IssuePrivacyTokensSpec { + /// JIDs to issue tokens for (should be LID JIDs). + pub jids: Vec, + /// Current timestamp to use for the token issuance. + pub timestamp: i64, +} + +impl IssuePrivacyTokensSpec { + pub fn new(jids: &[Jid]) -> Self { + Self { + jids: jids.to_vec(), + timestamp: unix_now(), + } + } +} + +/// Response from issuing privacy tokens. +#[derive(Debug, Clone, Default)] +pub struct IssuePrivacyTokensResponse { + /// Tokens received back from the server. + pub tokens: Vec, +} + +impl IqSpec for IssuePrivacyTokensSpec { + type Response = IssuePrivacyTokensResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + let token_nodes: Vec = self + .jids + .iter() + .map(|jid| { + NodeBuilder::new("token") + .attr("jid", jid.to_string()) + .attr("t", self.timestamp.to_string()) + .attr("type", "trusted_contact") + .build() + }) + .collect(); + + InfoQuery::set( + PRIVACY_NAMESPACE, + Jid::new("", SERVER_JID), + Some(NodeContent::Nodes(vec![ + NodeBuilder::new("tokens").children(token_nodes).build(), + ])), + ) + } + + fn parse_response(&self, response: &Node) -> Result { + let tokens_node = match response.get_optional_child("tokens") { + Some(n) => n, + None => return Ok(IssuePrivacyTokensResponse::default()), + }; + + let mut tokens = Vec::new(); + for token_node in tokens_node.get_children_by_tag("token") { + let jid_str = required_attr(token_node, "jid")?; + let jid: Jid = jid_str + .parse() + .map_err(|e| anyhow::anyhow!("invalid jid '{}': {}", jid_str, e))?; + let t_str = required_attr(token_node, "t")?; + let timestamp: i64 = t_str + .parse() + .map_err(|e| anyhow::anyhow!("invalid timestamp '{}': {}", t_str, e))?; + + let token_bytes = match &token_node.content { + Some(NodeContent::Bytes(data)) => data.clone(), + _ => { + log::warn!(target: "TcToken", "Token node for {} has no binary content, skipping", jid); + continue; + } + }; + + tokens.push(ReceivedTcToken { + jid, + token: token_bytes, + timestamp, + }); + } + + Ok(IssuePrivacyTokensResponse { tokens }) + } +} + +/// Parse incoming privacy_token notification. +/// +/// Extracts token data from a `` stanza. +/// Returns `ParsedTokenData` items without JID — the caller is responsible for +/// resolving the sender JID from the notification's `sender_lid` / `from` attributes. +pub fn parse_privacy_token_notification( + notification: &Node, +) -> Result, anyhow::Error> { + let tokens_node = required_child(notification, "tokens")?; + + let mut tokens = Vec::new(); + for token_node in tokens_node.get_children_by_tag("token") { + let token_type = optional_attr(token_node, "type").unwrap_or(""); + if token_type != "trusted_contact" { + continue; + } + + let t_str = required_attr(token_node, "t")?; + let timestamp: i64 = t_str.parse().map_err(|e| { + anyhow::anyhow!( + "invalid timestamp '{}' in privacy_token notification: {}", + t_str, + e + ) + })?; + + let token_bytes = match &token_node.content { + Some(NodeContent::Bytes(data)) => data.clone(), + _ => { + log::warn!(target: "TcToken", "Notification token node has no binary content, skipping"); + continue; + } + }; + + tokens.push(ParsedTokenData { + token: token_bytes, + timestamp, + }); + } + + Ok(tokens) +} + +/// Build a `` stanza child for including in outgoing messages. +pub fn build_tc_token_node(token: &[u8]) -> Node { + NodeBuilder::new("tctoken").bytes(token.to_vec()).build() +} + +/// Build a `` stanza child with timestamp attribute. +pub fn build_tc_token_node_with_timestamp(token: &[u8], timestamp: i64) -> Node { + NodeBuilder::new("tctoken") + .attr("t", timestamp.to_string()) + .bytes(token.to_vec()) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bucket_index() { + assert_eq!(bucket_index(0), 0); + assert_eq!(bucket_index(604799), 0); + assert_eq!(bucket_index(604800), 1); + assert_eq!(bucket_index(1209599), 1); + assert_eq!(bucket_index(1209600), 2); + } + + #[test] + fn test_should_send_new_tc_token_none() { + assert!(should_send_new_tc_token_at(None, 1_000_000)); + } + + #[test] + fn test_should_send_new_tc_token_same_bucket() { + let now = 2 * TC_TOKEN_BUCKET_DURATION + 100; + let same_bucket_ts = 2 * TC_TOKEN_BUCKET_DURATION; + assert!(!should_send_new_tc_token_at(Some(same_bucket_ts), now)); + } + + #[test] + fn test_should_send_new_tc_token_different_bucket() { + let now = 3 * TC_TOKEN_BUCKET_DURATION + 100; + let old_ts = 1 * TC_TOKEN_BUCKET_DURATION + 50; + assert!(should_send_new_tc_token_at(Some(old_ts), now)); + } + + #[test] + fn test_should_send_new_tc_token_clock_backward_no_reissue() { + // If clock goes backwards, should NOT trigger re-issuance (> not !=) + let future_ts = 5 * TC_TOKEN_BUCKET_DURATION + 100; + let now = 3 * TC_TOKEN_BUCKET_DURATION + 100; + assert!(!should_send_new_tc_token_at(Some(future_ts), now)); + } + + #[test] + fn test_is_tc_token_expired() { + let now = 10 * TC_TOKEN_BUCKET_DURATION; + + // Recent token should not be expired + assert!(!is_tc_token_expired_at(now - 100, now)); + + // Token older than total window should be expired + assert!(is_tc_token_expired_at( + now - TC_TOKEN_TOTAL_DURATION - 1, + now + )); + + // Token at exact boundary + assert!(is_tc_token_expired_at(now - TC_TOKEN_TOTAL_DURATION, now)); + } + + #[test] + fn test_tc_token_expiration_cutoff() { + let now = unix_now(); + let cutoff = tc_token_expiration_cutoff(); + let expected = now - TC_TOKEN_TOTAL_DURATION; + assert!((cutoff - expected).abs() <= 1); + } + + #[test] + fn test_issue_privacy_tokens_spec_build_iq() { + let jid1: Jid = "100000000000001@lid".parse().unwrap(); + let jid2: Jid = "100000000000002@lid".parse().unwrap(); + let spec = IssuePrivacyTokensSpec { + jids: vec![jid1, jid2], + timestamp: 1707000000, + }; + let iq = spec.build_iq(); + + assert_eq!(iq.namespace, PRIVACY_NAMESPACE); + assert_eq!(iq.query_type, crate::request::InfoQueryType::Set); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].tag, "tokens"); + let token_children: Vec<_> = nodes[0].get_children_by_tag("token").collect(); + assert_eq!(token_children.len(), 2); + } else { + panic!("Expected NodeContent::Nodes"); + } + } + + #[test] + fn test_issue_privacy_tokens_spec_parse_response() { + let spec = IssuePrivacyTokensSpec { + jids: vec!["100000000000001@lid".parse().unwrap()], + timestamp: 1707000000, + }; + + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("tokens") + .children([NodeBuilder::new("token") + .attr("jid", "100000000000001@lid") + .attr("t", "1707000000") + .attr("type", "trusted_contact") + .bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]) + .build()]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert_eq!(result.tokens.len(), 1); + assert_eq!(result.tokens[0].jid.to_string(), "100000000000001@lid"); + assert_eq!(result.tokens[0].token, vec![0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!(result.tokens[0].timestamp, 1707000000); + } + + #[test] + fn test_issue_privacy_tokens_spec_parse_skips_empty_token() { + let spec = IssuePrivacyTokensSpec { + jids: vec!["100000000000001@lid".parse().unwrap()], + timestamp: 1707000000, + }; + + // Token node without binary content should be skipped + let response = NodeBuilder::new("iq") + .attr("type", "result") + .children([NodeBuilder::new("tokens") + .children([NodeBuilder::new("token") + .attr("jid", "100000000000001@lid") + .attr("t", "1707000000") + .attr("type", "trusted_contact") + .build()]) + .build()]) + .build(); + + let result = spec.parse_response(&response).unwrap(); + assert!(result.tokens.is_empty()); + } + + #[test] + fn test_parse_privacy_token_notification() { + let notification = NodeBuilder::new("notification") + .attr("type", "privacy_token") + .children([NodeBuilder::new("tokens") + .children([NodeBuilder::new("token") + .attr("type", "trusted_contact") + .attr("t", "1707000000") + .bytes(vec![0xCA, 0xFE]) + .build()]) + .build()]) + .build(); + + let tokens = parse_privacy_token_notification(¬ification).unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].token, vec![0xCA, 0xFE]); + assert_eq!(tokens[0].timestamp, 1707000000); + } + + #[test] + fn test_parse_privacy_token_notification_skips_non_trusted_contact() { + let notification = NodeBuilder::new("notification") + .children([NodeBuilder::new("tokens") + .children([ + NodeBuilder::new("token") + .attr("type", "other_type") + .attr("t", "1000") + .build(), + NodeBuilder::new("token") + .attr("type", "trusted_contact") + .attr("t", "2000") + .bytes(vec![0x01]) + .build(), + ]) + .build()]) + .build(); + + let tokens = parse_privacy_token_notification(¬ification).unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].timestamp, 2000); + } + + #[test] + fn test_parse_privacy_token_notification_skips_empty_content() { + let notification = NodeBuilder::new("notification") + .children([NodeBuilder::new("tokens") + .children([NodeBuilder::new("token") + .attr("type", "trusted_contact") + .attr("t", "1707000000") + .build()]) + .build()]) + .build(); + + let tokens = parse_privacy_token_notification(¬ification).unwrap(); + assert!(tokens.is_empty()); + } + + #[test] + fn test_build_tc_token_node() { + let node = build_tc_token_node(&[0x01, 0x02, 0x03]); + assert_eq!(node.tag, "tctoken"); + match &node.content { + Some(NodeContent::Bytes(data)) => assert_eq!(data, &[0x01, 0x02, 0x03]), + _ => panic!("Expected binary content"), + } + } + + #[test] + fn test_build_tc_token_node_with_timestamp() { + let node = build_tc_token_node_with_timestamp(&[0x01], 1707000000); + assert_eq!(node.tag, "tctoken"); + assert_eq!(node.attrs().optional_string("t"), Some("1707000000")); + } + + #[test] + fn test_issue_privacy_tokens_spec_empty_response() { + let spec = IssuePrivacyTokensSpec { + jids: vec![], + timestamp: 1707000000, + }; + + let response = NodeBuilder::new("iq").attr("type", "result").build(); + + let result = spec.parse_response(&response).unwrap(); + assert!(result.tokens.is_empty()); + } + + #[test] + fn test_issue_privacy_tokens_spec_new_from_slice() { + let jid1: Jid = "100000000000001@lid".parse().unwrap(); + let jid2: Jid = "100000000000002@lid".parse().unwrap(); + let jids = [jid1.clone(), jid2.clone()]; + let spec = IssuePrivacyTokensSpec::new(&jids); + assert_eq!(spec.jids.len(), 2); + assert_eq!(spec.jids[0], jid1); + assert_eq!(spec.jids[1], jid2); + } +} diff --git a/wacore/src/store/traits.rs b/wacore/src/store/traits.rs index bffab8d76..7c929194d 100644 --- a/wacore/src/store/traits.rs +++ b/wacore/src/store/traits.rs @@ -37,6 +37,19 @@ pub struct LidPnMappingEntry { pub learning_source: String, } +/// Trusted contact privacy token entry. +/// +/// Matches WhatsApp Web's Chat.tcToken / tcTokenTimestamp / tcTokenSenderTimestamp. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcTokenEntry { + /// Raw token bytes received from the server. + pub token: Vec, + /// Unix timestamp (seconds) when the token was received. + pub token_timestamp: i64, + /// Unix timestamp (seconds) when we last issued our token to this contact. + pub sender_timestamp: Option, +} + /// Device information for registry tracking. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceInfo { @@ -224,6 +237,23 @@ pub trait ProtocolStore: Send + Sync { /// Get participants that need fresh SKDM (marked for forget). /// Consumes the marks (deletes them after reading). async fn consume_forget_marks(&self, group_jid: &str) -> Result>; + + // --- TcToken Storage --- + + /// Get a trusted contact token for a JID (stored under LID). + async fn get_tc_token(&self, jid: &str) -> Result>; + + /// Store or update a trusted contact token for a JID. + async fn put_tc_token(&self, jid: &str, entry: &TcTokenEntry) -> Result<()>; + + /// Delete a trusted contact token for a JID. + async fn delete_tc_token(&self, jid: &str) -> Result<()>; + + /// Get all JIDs that have stored tc tokens. + async fn get_all_tc_token_jids(&self) -> Result>; + + /// Delete tc tokens with token_timestamp older than cutoff. Returns count deleted. + async fn delete_expired_tc_tokens(&self, cutoff_timestamp: i64) -> Result; } /// Device data persistence operations.