Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions src/appstate_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ mod tests {
async fn clear_all_sender_key_devices(&self) -> StoreResult<()> {
Ok(())
}
async fn delete_sender_key_device_rows(&self, _: &[&str]) -> StoreResult<()> {
Ok(())
}
async fn get_lid_mapping(&self, _: &str) -> StoreResult<Option<LidPnMappingEntry>> {
Ok(None)
}
Expand Down
390 changes: 337 additions & 53 deletions src/client/device_registry.rs

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions src/client/sender_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,72 @@ impl Client {
Ok(())
}

/// Forward-secrecy rotation when participants leave a group. Mirrors WA
/// Web's `removeParticipantInfo` (`GroupParticipantHelpers.js`): if any
/// removed user had `has_key=true`, delete the bot's own sender key for
/// the group and wipe `sender_key_devices` so the next send takes the
/// `force_skdm=true` path (`!key_exists`) and redistributes to all
/// remaining participants.
pub(crate) async fn rotate_sender_key_on_participant_remove(
&self,
group_jid: &str,
removed_user_ids: &[&str],
) {
if removed_user_ids.is_empty() {
return;
}

// Read failure → rotate anyway. Better to pay the redistribute cost
// than leave the sender key in place after a removal we couldn't audit.
let (rows, read_failed) = match self
.persistence_manager
.get_sender_key_devices(group_jid)
.await
{
Ok(r) => (r, false),
Err(e) => {
log::warn!(
"rotate_sender_key_on_participant_remove: read failed for {group_jid}: {e} \
— rotating conservatively"
);
(Vec::new(), true)
}
};

let any_had_key = rows.iter().any(|(jid_str, has_key)| {
*has_key
&& jid_str
.parse::<Jid>()
.ok()
.is_some_and(|jid| removed_user_ids.iter().any(|u| *u == jid.user.as_str()))
Comment on lines +103 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve aliases before deciding sender-key rotation

rotate_sender_key_on_participant_remove only treats a removed member as having the key when the removed ID exactly matches jid.user from persisted sender_key_devices rows. Since callers provide a single user form (participant.jid.user), this misses cases where the row is stored under the opposite PN/LID alias (which can happen as mappings are learned/updated). In that case any_had_key is false and rotation is skipped, so an actually removed participant who had previously received SKDM can still decrypt subsequent group messages.

Useful? React with 👍 / 👎.

});
if !read_failed && !any_had_key {
return;
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rotate conservatively when sender-key tracker is empty

This early return treats an empty sender_key_devices read as proof that no removed participant had the key, but the new phash-mismatch path now calls clear_sender_key_devices without deleting the current sender key first. In that state, a later participant removal will skip rotation here, keep using the old sender key, and a previously keyed removed member can still decrypt future group messages. Empty tracker state is "unknown," so this path should rotate (or otherwise force key replacement) instead of returning.

Useful? React with 👍 / 👎.

}

use wacore::libsignal::store::sender_key_name::SenderKeyName;
use wacore::types::jid::JidExt;
let snapshot = self.persistence_manager.get_device_snapshot().await;
for own_jid in snapshot.lid.iter().chain(snapshot.pn.iter()) {
let sk_name =
SenderKeyName::from_parts(group_jid, own_jid.to_protocol_address().as_str());
self.signal_cache
.delete_sender_key(sk_name.cache_key())
.await;
}
self.flush_signal_cache_logged("rotate_sender_key_on_participant_remove", None)
.await;

if let Err(e) = self
.persistence_manager
.clear_sender_key_devices(group_jid)
.await
{
log::warn!("rotate_sender_key_on_participant_remove: clear DB failed: {e}");
}
self.sender_key_device_cache.invalidate(group_jid).await;
}

/// Take a sent message for retry handling. Checks L1 cache first (if enabled),
/// then falls back to DB. On miss, tries an alternate PN/LID key to handle
/// mapping changes between send time and retry time (WAWebLidMigrationUtils
Expand Down
3 changes: 3 additions & 0 deletions src/features/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ impl<'a> Groups<'a> {
info.remove_participants(&accepted);
group_cache.insert(jid.clone(), info).await;
}
self.client
.rotate_sender_key_on_participant_remove(&jid.to_string(), &accepted)
.await;
}
Ok(result)
}
Expand Down
9 changes: 7 additions & 2 deletions src/handlers/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1192,10 +1192,9 @@ async fn handle_group_notification(client: &Arc<Client>, node: Arc<OwnedNodeRef>
}
}
GroupNotificationAction::Remove { participants, .. } => {
let users: Vec<&str> = participants.iter().map(|p| p.jid.user.as_str()).collect();
let group_cache = client.get_group_cache().await;
if let Some(mut info) = group_cache.get(&notification.group_jid).await {
let users: Vec<&str> =
participants.iter().map(|p| p.jid.user.as_str()).collect();
info.remove_participants(&users);
group_cache
.insert(notification.group_jid.clone(), info)
Expand All @@ -1206,6 +1205,12 @@ async fn handle_group_notification(client: &Arc<Client>, node: Arc<OwnedNodeRef>
notification.group_jid, participants.len()
);
}
client
.rotate_sender_key_on_participant_remove(
&notification.group_jid.to_string(),
&users,
)
.await;
}
_ => {}
}
Expand Down
82 changes: 73 additions & 9 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,10 +696,38 @@ impl Client {
client.invalidate_device_cache(&own_pn.user).await;
}
}
client
.sender_key_device_cache
.invalidate(&jid.to_string())
.await;
let jid_str = jid.to_string();
// Cache-only invalidation re-reads the same stale rows on
// the next send. Drop the persisted state too so the next
// send takes the full-distribution path. If the clear
// fails, fall back to deleting the bot's own sender key
// for the chat — the next send will see `!key_exists`
// and force_skdm without depending on the tracker.
if jid.is_group() || jid.is_status_broadcast() {
let cleared = client
.persistence_manager
.clear_sender_key_devices(&jid_str)
.await;
if let Err(e) = cleared {
log::warn!(
"phash mismatch: clear_sender_key_devices failed: {e} — \
deleting own sender key as fallback to force redistribution"
);
use wacore::libsignal::store::sender_key_name::SenderKeyName;
use wacore::types::jid::JidExt;
let snapshot =
client.persistence_manager.get_device_snapshot().await;
for own in snapshot.lid.iter().chain(snapshot.pn.iter()) {
let sk =
SenderKeyName::from_parts(&jid_str, own.to_protocol_address().as_str());
client.signal_cache.delete_sender_key(sk.cache_key()).await;
}
let _ = client
.flush_signal_cache_logged("phash-mismatch-fallback", None)
.await;
}
}
client.sender_key_device_cache.invalidate(&jid_str).await;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if invalidate_group_cache {
client.get_group_cache().await.invalidate(&jid).await;
}
Expand Down Expand Up @@ -966,13 +994,40 @@ impl Client {
let sender_key_name = SenderKeyName::from_parts(&to_str, sender_address.as_str());

let device_guard = device_store_arc.read().await;
let key_exists = self
let record = self
.signal_cache
.get_sender_key(&sender_key_name, &*device_guard.backend)
.await?
.is_some();
.await?;
let key_exists = record.is_some();

// WA Web posts SenderKeyExpired with `PERIODIC_ROTATION` after
// a chain advances past a threshold. Captured-js doesn't show
// the value; 1000 mirrors common Signal hygiene defaults.
const SENDER_KEY_ROTATION_THRESHOLD: u32 = 1000;
let needs_rotation = record
.and_then(|mut r| r.sender_key_state_mut().ok().cloned())
.and_then(|state| state.sender_chain_key().map(|ck| ck.iteration()))
.is_some_and(|iter| iter >= SENDER_KEY_ROTATION_THRESHOLD);
drop(device_guard);

if needs_rotation {
log::info!(
"Periodic sender-key rotation for {to} (chain iteration ≥ {SENDER_KEY_ROTATION_THRESHOLD})"
);
self.signal_cache
.delete_sender_key(sender_key_name.cache_key())
.await;
if let Err(e) = self
.persistence_manager
.clear_sender_key_devices(&to_str)
.await
{
log::warn!("periodic rotation: clear_sender_key_devices failed: {e}");
}
self.sender_key_device_cache.invalidate(&to_str).await;
}

force_key_distribution || !key_exists
force_key_distribution || !key_exists || needs_rotation
};

let mut store_adapter = self.signal_adapter_from(device_store_arc.clone());
Expand Down Expand Up @@ -1231,7 +1286,16 @@ impl Client {
}

if let Some((rx, phash, msg_id)) = ack {
self.spawn_phash_validation(rx, phash, tc_issue_target.clone(), false, msg_id);
// Group sends also invalidate group cache on mismatch — server's
// participant set diverged, the next send needs a fresh query.
let invalidate_group = tc_issue_target.is_group();
self.spawn_phash_validation(
rx,
phash,
tc_issue_target.clone(),
invalidate_group,
msg_id,
);
}

if let Some(update) = skdm_update {
Expand Down
21 changes: 17 additions & 4 deletions src/sender_key_device_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,23 @@ impl SenderKeyDeviceCache {
self.inner.invalidate(group_jid).await;
}

/// Invalidate all entries. Used on raw_id mismatch (identity change)
/// to force SKDM redistribution for all groups.
pub(crate) fn invalidate_all(&self) {
self.inner.invalidate_all();
/// Drop cache entries whose map indexes the given (user, device_id). Needed
/// after a device is removed: a future re-add of the same device_id would
/// otherwise hit a stale `has_key=true` entry and skip SKDM redistribution.
pub(crate) async fn invalidate_entries_for_device(&self, user: &str, device_id: u16) {
let to_drop: Vec<String> = self
.inner
.iter()
.filter_map(|(group_jid, map)| {
map.devices
.get(user)
.and_then(|devmap| devmap.get(&device_id))
.map(|_| group_jid.as_ref().clone())
})
.collect();
for g in to_drop {
self.inner.invalidate(&g).await;
}
}

#[cfg(feature = "debug-diagnostics")]
Expand Down
9 changes: 9 additions & 0 deletions src/store/persistence_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ impl PersistenceManager {
pub async fn clear_sender_key_devices(&self, group_jid: &str) -> Result<(), StoreError> {
self.backend.clear_sender_key_devices(group_jid).await
}

pub async fn delete_sender_key_device_rows(
&self,
device_jids: &[&str],
) -> Result<(), StoreError> {
self.backend
.delete_sender_key_device_rows(device_jids)
.await
}
}

#[cfg(test)]
Expand Down
24 changes: 24 additions & 0 deletions storages/sqlite-storage/src/sqlite_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1854,6 +1854,30 @@ impl ProtocolStore for SqliteStore {
.await
}

async fn delete_sender_key_device_rows(&self, device_jids: &[&str]) -> Result<()> {
if device_jids.is_empty() {
return Ok(());
}
let device_id = self.device_id;
let owned: Arc<Vec<String>> = Arc::new(device_jids.iter().map(|s| s.to_string()).collect());
self.with_retry("delete_sender_key_device_rows", || {
let owned = Arc::clone(&owned);
Box::new(move |conn: &mut SqliteConnection| {
const CHUNK: usize = 190;
for chunk in owned.chunks(CHUNK) {
diesel::delete(
sender_key_devices::table
.filter(sender_key_devices::device_jid.eq_any(chunk))
.filter(sender_key_devices::device_id.eq(device_id)),
)
.execute(conn)?;
}
Ok(())
})
})
.await
}

async fn get_lid_mapping(&self, lid: &str) -> Result<Option<LidPnMappingEntry>> {
let pool = self.pool.clone();
let device_id = self.device_id;
Expand Down
12 changes: 12 additions & 0 deletions wacore/src/store/in_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,18 @@ impl ProtocolStore for InMemoryBackend {
Ok(())
}

async fn delete_sender_key_device_rows(&self, device_jids: &[&str]) -> Result<()> {
if device_jids.is_empty() {
return Ok(());
}
let mut state = self.state.lock().await;
let targets: std::collections::HashSet<&str> = device_jids.iter().copied().collect();
for group_map in state.sender_key_devices.values_mut() {
group_map.retain(|jid, _| !targets.contains(jid.as_str()));
}
Ok(())
}

// --- LID-PN Mapping ---

async fn get_lid_mapping(&self, lid: &str) -> Result<Option<LidPnMappingEntry>> {
Expand Down
9 changes: 9 additions & 0 deletions wacore/src/store/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,13 @@ impl PersistenceManager {
pub async fn clear_sender_key_devices(&self, group_jid: &str) -> Result<(), StoreError> {
self.backend.clear_sender_key_devices(group_jid).await
}

pub async fn delete_sender_key_device_rows(
&self,
device_jids: &[&str],
) -> Result<(), StoreError> {
self.backend
.delete_sender_key_device_rows(device_jids)
.await
}
}
4 changes: 4 additions & 0 deletions wacore/src/store/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ pub trait ProtocolStore: Send + Sync {
/// Clear all sender key device tracking for a group (on sender key rotation).
async fn clear_sender_key_devices(&self, group_jid: &str) -> Result<()>;

/// Delete specific `sender_key_devices` rows by device JID across all groups.
/// Mirrors WA Web's per-group `senderKey.delete(deviceJid)` cleanup.
async fn delete_sender_key_device_rows(&self, device_jids: &[&str]) -> Result<()>;

/// Clear all sender key device tracking across ALL groups.
/// Called on identity change (raw_id mismatch) to force SKDM redistribution.
async fn clear_all_sender_key_devices(&self) -> Result<()>;
Expand Down
Loading