Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/features/tctoken.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
//! let count = client.tc_token().prune_expired().await?;
//! ```
//!
//! ## TODO: VoIP call integration
//! WA Web calls `sendTcToken` for each participant when initiating calls
//! (WAWeb/Voip/StartCall.js). When a calls/VoIP module is added, it should
//! call `issue_tc_token_after_send` (or equivalent) for every call participant
//! — both 1:1 and group calls. This prevents 463 nacks on call offers.
//! ## VoIP call integration
//! Outgoing 1:1 call offers attach the callee's stored token to the offer's
//! `<privacy>` node and issue a fresh token after send (WA Web's `sendTcToken`
//! in StartCall.js), both driven from `voip::facade::place_call`. Group-call
//! initiation is not yet implemented; when it is, it should attach/issue per
//! participant the same way to avoid 463 nacks on call offers.

use crate::client::Client;
use crate::request::IqError;
Expand Down
95 changes: 94 additions & 1 deletion src/send/tctoken_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ impl Client {
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.issue_tc_token", level = "debug", skip_all, fields(to = %to.observe())))]
pub(super) async fn issue_tc_token_after_send(&self, to: &Jid) {
pub(crate) async fn issue_tc_token_after_send(&self, to: &Jid) {
use wacore::iq::tctoken::IssuePrivacyTokensSpec;

// Bots and status broadcast don't participate in the privacy token system.
Expand All @@ -144,6 +144,45 @@ impl Client {
self.record_tc_token_sender_timestamp(to).await;
}

/// Whether a fresh tctoken should be issued to `to`, rate-limited by the
/// sender bucket. Independent of the 1:1 message AB props — WA Web schedules
/// `sendTcToken` on its own cadence (`MsgJob`, `StartCall`) regardless of
/// whether a token was attached to the outgoing stanza.
#[cfg(feature = "voip")]
pub(crate) async fn should_issue_tc_token(&self, to: &Jid) -> bool {
use wacore::iq::tctoken::should_send_new_tc_token_with;

// A self-call must never issue or record a token for our own account
// (same guard as maybe_include_tc_token).
let snapshot = self.persistence_manager.get_device_snapshot();
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 false;
}

if to.is_bot() || to.is_status_broadcast() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return false;
}

let key = self.resolve_tc_token_key(to).await;
let sender_ts = match self.persistence_manager.backend().get_tc_token(&key).await {
Ok(entry) => entry.and_then(|e| e.sender_timestamp),
Err(e) => {
log::warn!(target: "Client/TcToken", "Failed to read tc_token for {}: {e}", to.observe());
None
}
};

should_send_new_tc_token_with(sender_ts, &self.tc_token_config().await)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Persist tokens returned by the explicit `tc_token().issue_tokens()` API.
pub(crate) async fn store_issued_tc_tokens(
&self,
Expand Down Expand Up @@ -425,4 +464,58 @@ mod tests {
"sender_timestamp is advanced on issuance"
);
}

#[cfg(feature = "voip")]
#[tokio::test]
async fn should_issue_tc_token_true_for_unknown_contact() {
let client = create_test_client().await;
let jid = Jid::new("770000003", Server::Lid);
assert!(
client.should_issue_tc_token(&jid).await,
"a contact with no recorded issuance should get a token"
);
}

#[cfg(feature = "voip")]
#[tokio::test]
async fn should_issue_tc_token_false_within_sender_bucket() {
let client = create_test_client().await;
client
.persistence_manager
.backend()
.put_tc_token(
"770000004",
&TcTokenEntry {
token: Vec::new(),
token_timestamp: 0,
sender_timestamp: Some(wacore::time::now_secs()),
},
)
.await
.unwrap();

let jid = Jid::new("770000004", Server::Lid);
assert!(
!client.should_issue_tc_token(&jid).await,
"a fresh issuance in the current bucket must not re-issue"
);
}

#[cfg(feature = "voip")]
#[tokio::test]
async fn should_issue_tc_token_false_for_self() {
let client = create_test_client().await;
let own = Jid::new("999000111", Server::Lid);
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetLid(Some(
own.clone(),
)))
.await;

assert!(
!client.should_issue_tc_token(&own).await,
"a self-call must never issue a tc token for our own account"
);
}
}
92 changes: 91 additions & 1 deletion src/voip/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,9 @@ async fn place_call(
Err(_) => return Err(CallError::MissingDeviceIdentity),
};

// A privacy-restricted callee rejects the offer unless it carries our stored token for them.
let privacy_token = client.lookup_tc_token_for_jid(peer).await;

// The offer needs a stanza id so the server can ack-correlate it: the initiator's relay rides
// back on the `<ack type=offer>` reply to THIS id, not on a later <call>.
let offer_stanza_id = client.generate_request_id();
Expand All @@ -520,7 +523,7 @@ async fn place_call(
to: peer,
call_creator,
device_keys: &device_keys,
privacy_token: None,
privacy_token: privacy_token.as_deref(),
capability: Some(&CAPABILITY_OFFER),
device_identity: device_identity.as_deref(),
id: Some(&offer_stanza_id),
Expand Down Expand Up @@ -607,6 +610,9 @@ async fn place_call(
return Err(e.into());
}

// Prevents 463 nacks on later offers to this contact (WA Web's post-offer sendTcToken).
spawn_call_tc_token_issuance(client, peer);

// The relay arrives in the `<ack type=offer>` reply to the offer's stanza id (live-only, needs a
// real server). Wait on the ack-waiter with a bounded timeout; attach the engine when the relay
// lands, else fail the call so a parked wait_ended() resolves.
Expand Down Expand Up @@ -697,6 +703,25 @@ fn spawn_outgoing_relay_waiter(
.detach();
}

/// Fire-and-forget issuance of our trusted-contact token to the callee after an outgoing offer,
/// mirroring WA Web's `sendTcToken` in StartCall.js. Rate-limited by the sender bucket so repeat
/// calls to the same contact within a window don't re-issue. Best-effort: a failed issuance only
/// risks a later re-issue, never the call itself.
fn spawn_call_tc_token_issuance(client: &Client, peer: &Jid) {
let Some(client) = client.self_weak.get().and_then(|w| w.upgrade()) else {
return;
};
let runtime = client.runtime.clone();
let peer = peer.clone();
runtime
.spawn(Box::pin(async move {
if client.should_issue_tc_token(&peer).await {
client.issue_tc_token_after_send(&peer).await;
}
}))
.detach();
}

/// Tear down a still-dormant outgoing call that never got its relay: drop the (generation-guarded)
/// pending entry, reap the registry generation, and notify its `ended` so wait_ended() resolves. A
/// no-op if the call was already hung up or superseded.
Expand Down Expand Up @@ -1885,6 +1910,71 @@ mod tests {
);
}

// A stored token must ride on the offer as the leading `<privacy>` child, or a
// privacy-restricted callee rejects the call (the no-token case is covered above).
#[tokio::test]
async fn place_call_attaches_stored_tctoken_as_privacy_node() {
use wacore::store::traits::TcTokenEntry;

let (client, _sent_count) = make_sending_client().await;
let peer_user = Jid::new("333333333333333", Server::Lid);
let device = peer_lid();
seed_peer_session(&client, &device).await;

// Keyed by the callee's account LID user (resolve_tc_token_key for a LID peer).
client
.persistence_manager
.backend()
.put_tc_token(
"333333333333333",
&TcTokenEntry {
token: vec![0xAB, 0xCD, 0xEF],
token_timestamp: wacore::time::now_secs(),
sender_timestamp: None,
},
)
.await
.unwrap();

let own_lid = client.get_lid().expect("own lid");
let (_mic_tx, mic_rx) = async_channel::unbounded::<Vec<i16>>();
let (spk_tx, _spk_rx) = async_channel::unbounded::<Vec<i16>>();

let waiter = client.wait_for_sent_node(crate::client::NodeFilter::tag("call"));

place_call(
&client,
"00abcdef0123456789abcdef01234567".into(),
&peer_user,
&own_lid,
&own_lid,
std::slice::from_ref(&device),
std::slice::from_ref(&device),
Arc::new(mic_rx),
Arc::new(spk_tx),
)
.await
.expect("place_call");

let node = tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("offer must be sent")
.expect("waiter");
let r = node.as_node_ref();
let offer = &r.children().unwrap()[0];
let children = offer.children().unwrap();
// `<privacy>` is the load-bearing first child of the offer.
assert_eq!(
children[0].tag.as_ref(),
"privacy",
"the stored tctoken must ride as the leading <privacy> child"
);
assert_eq!(
children[0].content_bytes(),
Some([0xAB, 0xCD, 0xEF].as_slice())
);
}

// Finding S: a per-device encrypt failure must SKIP that device, not abort the whole offer (which
// would strand the already-encrypted devices with an advanced chain for a ciphertext they never
// receive). Two seeded devices and one with no session: the offer still goes out, addressed to the
Expand Down
Loading