use crate::iq::spec::IqSpec;
use crate::request::InfoQuery;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::{Jid, Server};
use wacore_binary::{Node, NodeContent, NodeRef};
use super::privacy::PRIVACY_NAMESPACE;
pub const TC_TOKEN_BUCKET_DURATION: i64 = 604_800;
pub const TC_TOKEN_NUM_BUCKETS: i64 = 4;
pub const TC_TOKEN_MAX_DURATION: i64 = 15_552_000;
#[derive(Debug, Clone, Copy)]
pub struct TcTokenConfig {
pub bucket_duration: i64,
pub num_buckets: i64,
pub sender_bucket_duration: i64,
pub sender_num_buckets: i64,
}
impl TcTokenConfig {
pub fn clamped(self) -> Self {
Self {
bucket_duration: self.bucket_duration.clamp(1, TC_TOKEN_MAX_DURATION),
num_buckets: self.num_buckets.max(1),
sender_bucket_duration: self.sender_bucket_duration.clamp(1, TC_TOKEN_MAX_DURATION),
sender_num_buckets: self.sender_num_buckets.max(1),
}
}
}
impl Default for TcTokenConfig {
fn default() -> Self {
Self {
bucket_duration: TC_TOKEN_BUCKET_DURATION,
num_buckets: TC_TOKEN_NUM_BUCKETS,
sender_bucket_duration: TC_TOKEN_BUCKET_DURATION,
sender_num_buckets: TC_TOKEN_NUM_BUCKETS,
}
}
}
fn unix_now() -> i64 {
crate::time::now_secs()
}
pub fn is_tc_token_expired_with(token_timestamp: i64, config: &TcTokenConfig) -> bool {
is_tc_token_expired_with_at(token_timestamp, config, unix_now())
}
pub fn is_tc_token_expired_with_at(token_timestamp: i64, config: &TcTokenConfig, now: i64) -> bool {
let cfg = config.clamped();
is_tc_token_expired_at(token_timestamp, now, cfg.bucket_duration, cfg.num_buckets)
}
pub fn is_sender_tc_token_expired(sender_timestamp: i64, config: &TcTokenConfig) -> bool {
let cfg = config.clamped();
is_tc_token_expired_at(
sender_timestamp,
unix_now(),
cfg.sender_bucket_duration,
cfg.sender_num_buckets,
)
}
fn is_tc_token_expired_at(
token_timestamp: i64,
now: i64,
bucket_duration: i64,
num_buckets: i64,
) -> bool {
token_timestamp < expiration_cutoff_at(now, bucket_duration, num_buckets)
}
fn bucket_index(timestamp: i64, bucket_duration: i64) -> i64 {
timestamp / bucket_duration
}
fn expiration_cutoff_at(now: i64, bucket_duration: i64, num_buckets: i64) -> i64 {
let current_bucket = bucket_index(now, bucket_duration);
let expired_bucket = current_bucket - (num_buckets - 1);
expired_bucket * bucket_duration
}
pub fn should_send_new_tc_token_with(
sender_timestamp: Option<i64>,
config: &TcTokenConfig,
) -> bool {
should_send_new_tc_token_with_at(sender_timestamp, config, unix_now())
}
pub fn should_send_new_tc_token_with_at(
sender_timestamp: Option<i64>,
config: &TcTokenConfig,
now: i64,
) -> bool {
let cfg = config.clamped();
should_send_new_tc_token_at(sender_timestamp, now, cfg.sender_bucket_duration)
}
fn should_send_new_tc_token_at(
sender_timestamp: Option<i64>,
now: i64,
bucket_duration: i64,
) -> bool {
match sender_timestamp {
None => true,
Some(ts) => bucket_index(now, bucket_duration) > bucket_index(ts, bucket_duration),
}
}
pub fn tc_token_expiration_cutoff() -> i64 {
expiration_cutoff_at(unix_now(), TC_TOKEN_BUCKET_DURATION, TC_TOKEN_NUM_BUCKETS)
}
pub fn tc_token_expiration_cutoff_with(config: &TcTokenConfig) -> i64 {
let cfg = config.clamped();
expiration_cutoff_at(unix_now(), cfg.bucket_duration, cfg.num_buckets)
}
pub fn sender_tc_token_expiration_cutoff_with(config: &TcTokenConfig) -> i64 {
let cfg = config.clamped();
expiration_cutoff_at(
unix_now(),
cfg.sender_bucket_duration,
cfg.sender_num_buckets,
)
}
#[derive(Debug, Clone)]
pub struct ReceivedTcToken {
pub jid: Jid,
pub token: Vec<u8>,
pub timestamp: i64,
}
#[derive(Debug, Clone)]
pub struct ParsedTokenData {
pub token: Vec<u8>,
pub timestamp: i64,
}
pub struct IssuePrivacyTokensSpec {
pub jids: Vec<Jid>,
pub timestamp: i64,
}
impl IssuePrivacyTokensSpec {
pub fn new(jids: &[Jid]) -> Self {
Self {
jids: jids.to_vec(),
timestamp: unix_now(),
}
}
pub fn with_timestamp(jids: &[Jid], timestamp: i64) -> Self {
Self {
jids: jids.to_vec(),
timestamp,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IssuePrivacyTokensResponse {
pub tokens: Vec<ReceivedTcToken>,
}
impl IqSpec for IssuePrivacyTokensSpec {
type Response = IssuePrivacyTokensResponse;
fn build_iq(&self) -> InfoQuery<'static> {
let token_nodes: Vec<Node> = self
.jids
.iter()
.map(|jid| {
NodeBuilder::new("token")
.attr("jid", jid)
.attr("t", self.timestamp)
.attr("type", "trusted_contact")
.build()
})
.collect();
InfoQuery::set(
PRIVACY_NAMESPACE,
Jid::new("", Server::Pn),
Some(NodeContent::Nodes(vec![
NodeBuilder::new("tokens").children(token_nodes).build(),
])),
)
}
fn parse_response(&self, response: &NodeRef<'_>) -> Result<Self::Response, anyhow::Error> {
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: Jid = token_node
.attrs()
.optional_jid("jid")
.ok_or_else(|| anyhow::anyhow!("missing required attribute jid"))?;
let t_str = token_node
.get_attr("t")
.map(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required attribute t"))?;
let timestamp: i64 = t_str
.parse()
.map_err(|e| anyhow::anyhow!("invalid timestamp '{}': {}", t_str, e))?;
let Some(token_data) = token_node.content_bytes() else {
log::warn!(target: "TcToken", "Token node for {} has no binary content, skipping", jid);
continue;
};
tokens.push(ReceivedTcToken {
jid,
token: token_data.to_vec(),
timestamp,
});
}
Ok(IssuePrivacyTokensResponse { tokens })
}
}
pub fn parse_privacy_token_notification(
notification: &NodeRef<'_>,
) -> Result<Vec<ParsedTokenData>, anyhow::Error> {
let tokens_node = notification
.get_optional_child("tokens")
.ok_or_else(|| anyhow::anyhow!("<tokens> child not found"))?;
let mut tokens = Vec::new();
for token_node in tokens_node.get_children_by_tag("token") {
let token_type = token_node
.get_attr("type")
.map(|v| v.as_str())
.unwrap_or_default();
if token_type != "trusted_contact" {
continue;
}
let t_str = token_node
.get_attr("t")
.map(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required attribute t"))?;
let timestamp: i64 = t_str.parse().map_err(|e| {
anyhow::anyhow!(
"invalid timestamp '{}' in privacy_token notification: {}",
t_str,
e
)
})?;
let Some(token_data) = token_node.content_bytes() else {
log::warn!(target: "TcToken", "Notification token node has no binary content, skipping");
continue;
};
tokens.push(ParsedTokenData {
token: token_data.to_vec(),
timestamp,
});
}
Ok(tokens)
}
pub fn compute_cs_token(salt: &[u8], recipient_lid: &str) -> Vec<u8> {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(salt).expect("HMAC-SHA256 accepts any key length");
mac.update(recipient_lid.as_bytes());
mac.finalize().into_bytes().to_vec()
}
pub fn build_cs_token_node(token: &[u8]) -> Node {
NodeBuilder::new("cstoken").bytes(token.to_vec()).build()
}
pub fn build_tc_token_node(token: &[u8]) -> Node {
NodeBuilder::new("tctoken").bytes(token.to_vec()).build()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivacyTokenChoice {
TcToken,
CsToken,
None,
}
pub fn choose_privacy_token(
tc_send_enabled: bool,
nct_send_enabled: bool,
has_valid_tc_token: bool,
can_build_cs_token: bool,
) -> PrivacyTokenChoice {
if tc_send_enabled && has_valid_tc_token {
PrivacyTokenChoice::TcToken
} else if nct_send_enabled && can_build_cs_token {
PrivacyTokenChoice::CsToken
} else {
PrivacyTokenChoice::None
}
}
#[cfg(test)]
mod tests {
use super::*;
const DUR: i64 = TC_TOKEN_BUCKET_DURATION;
const BUCKETS: i64 = TC_TOKEN_NUM_BUCKETS;
#[test]
fn test_bucket_index() {
assert_eq!(bucket_index(0, DUR), 0);
assert_eq!(bucket_index(604799, DUR), 0);
assert_eq!(bucket_index(604800, DUR), 1);
assert_eq!(bucket_index(1209599, DUR), 1);
assert_eq!(bucket_index(1209600, DUR), 2);
}
#[test]
fn supplied_instant_decides_the_same_bucket_boundary() {
let config = TcTokenConfig::default().clamped();
let issued = 10 * config.sender_bucket_duration;
let last_second_of_bucket = issued + config.sender_bucket_duration - 1;
assert!(!should_send_new_tc_token_with_at(
Some(issued),
&config,
last_second_of_bucket
));
assert!(should_send_new_tc_token_with_at(
Some(issued),
&config,
last_second_of_bucket + 1
));
let stamped = 10 * config.bucket_duration;
let still_valid = stamped + (config.num_buckets - 1) * config.bucket_duration;
assert!(!is_tc_token_expired_with_at(stamped, &config, still_valid));
assert!(is_tc_token_expired_with_at(
stamped,
&config,
still_valid + config.bucket_duration
));
}
#[test]
fn test_should_send_new_tc_token_none() {
assert!(should_send_new_tc_token_at(None, 1_000_000, DUR));
}
#[test]
fn test_should_send_new_tc_token_same_bucket() {
let now = 2 * DUR + 100;
let same_bucket_ts = 2 * DUR;
assert!(!should_send_new_tc_token_at(Some(same_bucket_ts), now, DUR));
}
#[test]
fn test_should_send_new_tc_token_different_bucket() {
let now = 3 * DUR + 100;
let old_ts = DUR + 50;
assert!(should_send_new_tc_token_at(Some(old_ts), now, DUR));
}
#[test]
fn test_should_send_new_tc_token_clock_backward_no_reissue() {
let future_ts = 5 * DUR + 100;
let now = 3 * DUR + 100;
assert!(!should_send_new_tc_token_at(Some(future_ts), now, DUR));
}
#[test]
fn test_is_tc_token_expired() {
let now = 10 * DUR;
assert!(!is_tc_token_expired_at(now - 100, now, DUR, BUCKETS));
assert!(!is_tc_token_expired_at(7 * DUR, now, DUR, BUCKETS));
assert!(is_tc_token_expired_at(7 * DUR - 1, now, DUR, BUCKETS));
assert!(is_tc_token_expired_at(6 * DUR, now, DUR, BUCKETS));
}
#[test]
fn test_is_tc_token_expired_mid_bucket() {
let now = 10 * DUR + DUR / 2;
assert!(!is_tc_token_expired_at(7 * DUR, now, DUR, BUCKETS));
assert!(is_tc_token_expired_at(7 * DUR - 1, now, DUR, BUCKETS));
}
#[test]
fn test_expiration_cutoff_is_bucket_aligned() {
let now = 10 * DUR + 12345;
let cutoff = expiration_cutoff_at(now, DUR, BUCKETS);
assert_eq!(cutoff % DUR, 0);
assert_eq!(cutoff, 7 * DUR);
}
#[test]
fn test_tc_token_expiration_cutoff() {
let now = unix_now();
let cutoff = tc_token_expiration_cutoff();
let expected = expiration_cutoff_at(now, DUR, BUCKETS);
assert!((cutoff - expected).abs() <= 1);
}
#[test]
fn test_custom_config_shorter_duration() {
let config = TcTokenConfig {
bucket_duration: 3600, num_buckets: 3,
sender_bucket_duration: 3600,
sender_num_buckets: 3,
};
let now = 10 * 3600;
assert!(!is_tc_token_expired_at(
8 * 3600,
now,
config.bucket_duration,
config.num_buckets
));
assert!(is_tc_token_expired_at(
8 * 3600 - 1,
now,
config.bucket_duration,
config.num_buckets
));
}
#[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.as_node_ref()).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,
};
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.as_node_ref()).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.as_node_ref()).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.as_node_ref()).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.as_node_ref()).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_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.as_node_ref()).unwrap();
assert!(result.tokens.is_empty());
}
#[test]
fn test_issue_privacy_tokens_spec_new_from_slice() {
let jid1: Jid = "alice@lid".parse().unwrap();
let jid2: Jid = "bob@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);
}
#[test]
fn test_compute_cs_token_deterministic() {
let salt = b"test_salt_bytes_16";
let lid = "alice@lid";
let token1 = compute_cs_token(salt, lid);
let token2 = compute_cs_token(salt, lid);
assert_eq!(token1, token2);
assert_eq!(token1.len(), 32); }
#[test]
fn test_compute_cs_token_different_lids() {
let salt = b"test_salt_bytes_16";
let token1 = compute_cs_token(salt, "alice@lid");
let token2 = compute_cs_token(salt, "bob@lid");
assert_ne!(token1, token2);
}
#[test]
fn test_compute_cs_token_different_salts() {
let lid = "alice@lid";
let token1 = compute_cs_token(b"salt_a", lid);
let token2 = compute_cs_token(b"salt_b", lid);
assert_ne!(token1, token2);
}
#[test]
fn test_compute_cs_token_known_answer() {
let salt = b"whatsapp_nct_salt_example";
let lid = "alice@lid";
let expected: [u8; 32] = [
0x7c, 0x6a, 0xfc, 0x32, 0x57, 0x85, 0xac, 0x3c, 0x4f, 0x57, 0x1e, 0x64, 0x8a, 0x3b,
0xb8, 0x22, 0xf0, 0xe2, 0xe4, 0x94, 0x34, 0x81, 0x2e, 0xd2, 0x80, 0x9a, 0xea, 0x2e,
0x70, 0x43, 0xb5, 0x76,
];
assert_eq!(compute_cs_token(salt, lid), expected);
}
#[test]
fn test_build_cs_token_node() {
let node = build_cs_token_node(&[0xAA, 0xBB, 0xCC]);
assert_eq!(node.tag, "cstoken");
match &node.content {
Some(NodeContent::Bytes(data)) => assert_eq!(data, &[0xAA, 0xBB, 0xCC]),
_ => panic!("Expected binary content"),
}
}
#[test]
fn choose_prefers_valid_tc_token() {
assert_eq!(
choose_privacy_token(true, true, true, true),
PrivacyTokenChoice::TcToken
);
}
#[test]
fn choose_falls_back_to_cs_token_when_tc_missing() {
assert_eq!(
choose_privacy_token(true, true, false, true),
PrivacyTokenChoice::CsToken
);
}
#[test]
fn choose_cs_token_when_tc_send_disabled() {
assert_eq!(
choose_privacy_token(false, true, false, true),
PrivacyTokenChoice::CsToken
);
assert_eq!(
choose_privacy_token(false, true, true, true),
PrivacyTokenChoice::CsToken
);
}
#[test]
fn choose_none_when_cs_token_unbuildable() {
assert_eq!(
choose_privacy_token(true, true, false, false),
PrivacyTokenChoice::None
);
assert_eq!(
choose_privacy_token(false, true, false, false),
PrivacyTokenChoice::None
);
}
#[test]
fn choose_none_when_all_disabled() {
assert_eq!(
choose_privacy_token(false, false, true, true),
PrivacyTokenChoice::None
);
}
}