Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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: 4 additions & 7 deletions src/appstate_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,11 @@ mod tests {
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl MsgSecretStore for MockBackend {
async fn put_msg_secret(
async fn put_msg_secrets(
&self,
_chat: &str,
_sender: &str,
_msg_id: &str,
_secret: &[u8],
) -> StoreResult<()> {
Ok(())
entries: Vec<wacore::store::traits::MsgSecretEntry>,
) -> StoreResult<usize> {
Ok(entries.len())
}

async fn get_msg_secret(
Expand Down
39 changes: 29 additions & 10 deletions src/cache_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::cache::Cache;
use serde::{Serialize, de::DeserializeOwned};

use crate::cache_store::TypedCache;
pub use wacore::msg_secret::{MsgSecretPolicy, MsgSecretRetention, OriginalMessageResolver};
pub use wacore::store::cache::CacheStore;

/// Configuration for a single cache instance.
Expand Down Expand Up @@ -198,11 +199,22 @@ pub struct CacheConfig {
pub sent_message_ttl_secs: u64,

// --- MsgSecret retention ---
/// TTL in seconds for stored `messageSecret` rows before periodic
/// cleanup. `0` (default) disables automatic pruning, matching
/// whatsmeow and WA Web. Set to a positive value (e.g. `30 * 86_400`
/// for 30 days) to bound DB growth on long-running deployments.
pub msg_secret_ttl_secs: u64,
/// How the per-message `messageSecret` store is managed (capture / seed /
/// prune). Default [`MsgSecretPolicy::Managed`] bounds DB growth: it seeds
/// only the still-relevant slice of history and prunes by a per-add-on-kind
/// event-time horizon. Set [`MsgSecretPolicy::Full`] to keep everything
/// forever, or [`MsgSecretPolicy::Disabled`] to persist nothing and delegate
/// to [`original_message_resolver`].
///
/// [`original_message_resolver`]: CacheConfig::original_message_resolver
pub msg_secret_policy: MsgSecretPolicy,
/// Per-add-on-kind retention horizons applied under `Managed`/`BotOnly`.
pub msg_secret_retention: MsgSecretRetention,
/// Optional app-supplied fallback consulted when an add-on's parent secret
/// is absent from the store (and its LID/PN alternates). Lets an app that
/// keeps its own message store own secret retention; required for the
/// `Disabled` policy to decrypt anything beyond what it has seen live.
pub original_message_resolver: Option<Arc<dyn OriginalMessageResolver>>,

// --- Custom store overrides ---
/// Per-cache custom store overrides.
Expand Down Expand Up @@ -233,7 +245,12 @@ impl std::fmt::Debug for CacheConfig {
.field("session_locks_capacity", &self.session_locks_capacity)
.field("chat_lanes_capacity", &self.chat_lanes_capacity)
.field("sent_message_ttl_secs", &self.sent_message_ttl_secs)
.field("msg_secret_ttl_secs", &self.msg_secret_ttl_secs)
.field("msg_secret_policy", &self.msg_secret_policy)
.field("msg_secret_retention", &self.msg_secret_retention)
.field(
"original_message_resolver",
&self.original_message_resolver.is_some(),
)
.field(
"cache_stores.group_cache",
&self.cache_stores.group_cache.is_some(),
Expand Down Expand Up @@ -273,10 +290,12 @@ impl Default for CacheConfig {
session_locks_capacity: 10_000,
chat_lanes_capacity: 5_000,
sent_message_ttl_secs: 300,
// Disabled by default — match whatsmeow and WA Web (neither
// expires stored secrets). Callers expecting long-lived bot
// conversations, polls, or reactions can opt in.
msg_secret_ttl_secs: 0,
// Bounded by default: seed only the still-relevant slice of history
// and prune by per-add-on-kind event-time horizons, so the store no
// longer accumulates a secret for every message forever.
msg_secret_policy: MsgSecretPolicy::default(),
msg_secret_retention: MsgSecretRetention::default(),
original_message_resolver: None,
cache_stores: CacheStores::default(),
}
}
Expand Down
281 changes: 281 additions & 0 deletions src/history_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,17 @@ impl Client {
}

async fn store_history_sync_msg_secrets(&self, records: Vec<HistoryMsgSecretRecord>) -> usize {
use wacore::msg_secret::{self, RetentionClass};
const SECRET_LEN: usize = wacore::reporting_token::MESSAGE_SECRET_SIZE;

let policy = self.cache_config.msg_secret_policy;
if !policy.persists() {
// Disabled: rely on the resolver / app store, seed nothing.
return 0;
}
let retention = &self.cache_config.msg_secret_retention;
let now = wacore::time::now_secs();

let device_snapshot = self.persistence_manager.get_device_snapshot().await;
let own_pn = device_snapshot.pn.as_ref().map(|j| j.to_non_ad());
let own_lid = device_snapshot.lid.as_ref().map(|j| j.to_non_ad());
Expand All @@ -259,6 +268,28 @@ impl Client {
let Ok(chat) = record.chat_id.parse::<Jid>() else {
continue;
};
let class = if chat.is_bot() {
RetentionClass::Bot
} else if record.is_poll_or_event {
RetentionClass::PollEvent
} else {
RetentionClass::Text
};
// BotOnly restores pre-#665: seed only bot-context secrets.
if policy.bot_only() && !chat.is_bot() {
continue;
}
// Drop secrets whose parent is already past its retention horizon:
// no add-on can still reference them, so seeding is pure waste. Full
// skips the filter (prunes() is false) and seeds everything.
if policy.prunes()
&& !msg_secret::within_seed_horizon(retention, class, record.timestamp, now)
{
continue;
}
let expires_at =
msg_secret::expires_at(policy, retention, class, record.timestamp, now);

let mut senders =
history_msg_secret_senders(&chat, &record, own_pn.as_ref(), own_lid.as_ref());
if chat.is_bot()
Expand Down Expand Up @@ -293,6 +324,7 @@ impl Client {
} else {
secret.clone()
},
expires_at,
});
}
}
Expand Down Expand Up @@ -578,4 +610,253 @@ mod tests {
assert_eq!(primary, Some(secret.clone()));
assert_eq!(alias, Some(secret));
}

/// One inbound history message in `chat`, stamped at `ts_secs`, optionally a
/// poll-creation message, carrying `secret`.
fn history_msg(
chat: &str,
msg_id: &str,
secret: &[u8],
ts_secs: u64,
is_poll: bool,
) -> wa::HistorySyncMsg {
let message = if is_poll {
wa::Message {
poll_creation_message: Some(Box::new(wa::message::PollCreationMessage::default())),
..Default::default()
}
} else {
wa::Message {
conversation: Some("historical".to_string()),
..Default::default()
}
};
wa::HistorySyncMsg {
message: Some(wa::WebMessageInfo {
key: wa::MessageKey {
remote_jid: Some(chat.to_string()),
from_me: Some(false),
id: Some(msg_id.to_string()),
participant: None,
},
message: Some(message),
message_secret: Some(secret.to_vec()),
message_timestamp: Some(ts_secs),
..Default::default()
}),
msg_order_id: Some(1),
}
}

fn history_notification(
chat: &str,
messages: Vec<wa::HistorySyncMsg>,
) -> HistorySyncNotification {
let history_sync = wa::HistorySync {
sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32,
conversations: vec![wa::Conversation {
id: chat.to_string(),
messages,
..Default::default()
}],
..Default::default()
};
let compressed = compress_history_sync(&history_sync);
HistorySyncNotification {
file_length: Some(compressed.len() as u64),
sync_type: Some(wa::message::HistorySyncType::InitialBootstrap as i32),
initial_hist_bootstrap_inline_payload: Some(compressed),
..Default::default()
}
}

async fn seeded_client(
name: &str,
policy: crate::cache_config::MsgSecretPolicy,
) -> Arc<Client> {
let cfg = crate::cache_config::CacheConfig {
msg_secret_policy: policy,
..Default::default()
};
let client = crate::test_utils::create_test_client_with_config(
name,
std::sync::Arc::new(crate::test_utils::MockHttpClient),
cfg,
)
.await;
client
.persistence_manager
.process_command(wacore::store::commands::DeviceCommand::SetId(Some(
"5511000000001:0@s.whatsapp.net".parse().unwrap(),
)))
.await;
client.is_running.store(true, Ordering::Relaxed);
client
}

#[tokio::test]
async fn history_seed_managed_drops_old_text_keeps_recent() {
use crate::cache_config::MsgSecretPolicy;
let client = seeded_client("seed_managed_text", MsgSecretPolicy::Managed).await;
let chat = "5511777776666@s.whatsapp.net";
let now = wacore::time::now_secs() as u64;
let old_ts = now - 60 * 86_400; // past the 30d text horizon
let recent_ts = now - 86_400; // within it

let notification = history_notification(
chat,
vec![
history_msg(chat, "OLD_TEXT", &[0x11u8; 32], old_ts, false),
history_msg(chat, "RECENT_TEXT", &[0x22u8; 32], recent_ts, false),
],
);
client
.process_history_sync_task("S1".to_string(), notification)
.await;

let backend = client.persistence_manager.backend();
assert_eq!(
backend
.get_msg_secret(chat, chat, "OLD_TEXT")
.await
.unwrap(),
None,
"a text secret past its 30d horizon must not be seeded"
);
assert_eq!(
backend
.get_msg_secret(chat, chat, "RECENT_TEXT")
.await
.unwrap(),
Some(vec![0x22u8; 32]),
"a recent text secret must be seeded"
);
}

#[tokio::test]
async fn history_seed_managed_keeps_old_poll_within_90d() {
use crate::cache_config::MsgSecretPolicy;
let client = seeded_client("seed_managed_poll", MsgSecretPolicy::Managed).await;
let chat = "5511777776666@s.whatsapp.net";
let now = wacore::time::now_secs() as u64;
let ts = now - 60 * 86_400; // past 30d text but within 90d poll/event

let notification = history_notification(
chat,
vec![history_msg(chat, "OLD_POLL", &[0x33u8; 32], ts, true)],
);
client
.process_history_sync_task("S2".to_string(), notification)
.await;

assert_eq!(
client
.persistence_manager
.backend()
.get_msg_secret(chat, chat, "OLD_POLL")
.await
.unwrap(),
Some(vec![0x33u8; 32]),
"a poll parent within the 90d horizon must be seeded even past 30d"
);
}

#[tokio::test]
async fn history_seed_full_keeps_old_text() {
use crate::cache_config::MsgSecretPolicy;
let client = seeded_client("seed_full", MsgSecretPolicy::Full).await;
let chat = "5511777776666@s.whatsapp.net";
let now = wacore::time::now_secs() as u64;
let old_ts = now - 365 * 86_400; // a year old

let notification = history_notification(
chat,
vec![history_msg(chat, "ANCIENT", &[0x44u8; 32], old_ts, false)],
);
client
.process_history_sync_task("S3".to_string(), notification)
.await;

assert_eq!(
client
.persistence_manager
.backend()
.get_msg_secret(chat, chat, "ANCIENT")
.await
.unwrap(),
Some(vec![0x44u8; 32]),
"Full seeds everything regardless of age"
);
}

#[tokio::test]
async fn history_seed_disabled_stores_nothing() {
use crate::cache_config::MsgSecretPolicy;
let client = seeded_client("seed_disabled", MsgSecretPolicy::Disabled).await;
let chat = "5511777776666@s.whatsapp.net";
let now = wacore::time::now_secs() as u64;

let notification = history_notification(
chat,
vec![history_msg(chat, "ANY", &[0x55u8; 32], now - 60, false)],
);
client
.process_history_sync_task("S4".to_string(), notification)
.await;

assert_eq!(
client
.persistence_manager
.backend()
.get_msg_secret(chat, chat, "ANY")
.await
.unwrap(),
None,
"Disabled persists nothing"
);
}

#[tokio::test]
async fn history_seed_managed_stamps_expires_at_from_message_time() {
use crate::cache_config::MsgSecretPolicy;
let client = seeded_client("seed_expires", MsgSecretPolicy::Managed).await;
let chat = "5511777776666@s.whatsapp.net";
let now = wacore::time::now_secs();
let msg_ts = (now - 86_400) as u64; // 1 day old text → expires at msg_ts + 30d

let notification = history_notification(
chat,
vec![history_msg(chat, "RECENT", &[0x66u8; 32], msg_ts, false)],
);
client
.process_history_sync_task("S5".to_string(), notification)
.await;

let backend = client.persistence_manager.backend();
// Deadline is msg_ts + 30d ≈ now + 29d: a prune at "now" keeps it.
backend.delete_expired_msg_secrets(now).await.unwrap();
assert!(
backend
.get_msg_secret(chat, chat, "RECENT")
.await
.unwrap()
.is_some(),
"row must survive a prune before its deadline"
);
// A prune past msg_ts + 30d removes it, proving the deadline tracks
// message time, not seed time.
let removed = backend
.delete_expired_msg_secrets(now + 31 * 86_400)
.await
.unwrap();
assert_eq!(removed, 1);
assert!(
backend
.get_msg_secret(chat, chat, "RECENT")
.await
.unwrap()
.is_none(),
"row must be pruned once its message-time deadline passes"
);
}
}
Loading
Loading