Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
6 changes: 6 additions & 0 deletions src/appstate_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ mod tests {
async fn clear_sender_key_devices(&self, _: &str) -> StoreResult<()> {
Ok(())
}
async fn clear_all_sender_key_devices(&self) -> StoreResult<()> {
Ok(())
}
async fn get_lid_mapping(&self, _: &str) -> StoreResult<Option<LidPnMappingEntry>> {
Ok(None)
}
Expand Down Expand Up @@ -188,6 +191,9 @@ mod tests {
async fn get_devices(&self, _: &str) -> StoreResult<Option<DeviceListRecord>> {
Ok(None)
}
async fn delete_devices(&self, _: &str) -> StoreResult<()> {
Ok(())
}
async fn get_tc_token(
&self,
_: &str,
Expand Down
13 changes: 13 additions & 0 deletions src/client/context_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ impl SendContextResolver for Client {
) -> Result<HashMap<Jid, PreKeyBundle>, anyhow::Error> {
self.fetch_pre_keys(jids, Some(PreKeyFetchReason::Identity))
.await
.map_err(|e| {
// Re-wrap server errors as wacore::ServerErrorCode so
// encrypt_for_devices can downcast across crate boundaries
if let Some(crate::request::IqError::ServerError { code, text }) =
e.downcast_ref::<crate::request::IqError>()
{
return anyhow::Error::new(wacore::request::ServerErrorCode {
code: *code,
text: text.clone(),
});
}
e
})
}

async fn resolve_group_info(&self, jid: &Jid) -> Result<GroupInfo, anyhow::Error> {
Expand Down
162 changes: 146 additions & 16 deletions src/client/device_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,34 +181,151 @@ impl Client {

for key in lookup.all_keys() {
self.device_registry_cache.invalidate(key).await;
// Also delete from DB so get_devices_from_registry doesn't
// fall back to stale persisted data — forces a network re-fetch
if let Err(e) = self.persistence_manager.backend().delete_devices(key).await {
warn!("Failed to delete device registry from DB for {key}: {e}");
}
}

debug!("Invalidated device cache for user: {} ({:?})", user, lookup);
}

/// Granularly patch device registry after a device notification.
/// Patch device registry after a device add notification.
///
/// Matches WA Web's `handleDeviceAddNotification()` in `AdvDeviceNotificationApi`:
/// 1. Decode `key-index-list` signed bytes → `ADVKeyIndexList`
/// 2. Filter existing devices by `valid_indexes` (prune stale devices)
/// 3. Add the new device
/// 4. Replace the full device record
///
/// Matches WA Web's approach: read current → apply diff → write back.
/// Looks up the record from cache first, then falls back to the backend
/// DB so notifications are never silently dropped.
/// If `signed_bytes` is absent, falls back to simple append (lenient).
pub(crate) async fn patch_device_add(
&self,
user: &str,
device: &wacore::stanza::devices::DeviceElement,
key_index_info: Option<&wacore::stanza::devices::KeyIndexInfo>,
) {
let device_id = device.device_id();

if let Some(mut record) = self.load_device_record(user).await
&& !record.devices.iter().any(|d| d.device_id == device_id)
{
let Some(mut record) = self.load_device_record(user).await else {
return;
};

let signed_bytes = key_index_info.and_then(|ki| ki.signed_bytes.as_deref());

if let Some(bytes) = signed_bytes {
if let Some(decoded) = wacore::adv::decode_key_index_list(bytes) {
// Check raw_id mismatch (identity change)
if let Some(stored_raw_id) = record.raw_id
&& stored_raw_id != decoded.raw_id
{
info!(
"raw_id mismatch for user {user}: stored={stored_raw_id}, received={}. Clearing record.",
decoded.raw_id
);
self.clear_device_record(user, &device.jid.server, &record)
.await;
Comment on lines +230 to +231

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 Pass canonical JID when clearing sessions on add raw_id reset

On raw_id mismatch in patch_device_add, this call uses the notification alias (user + device.jid.server) instead of the canonical identity stored in the record, so clear_device_record can delete only PN-addressed sessions for mapped users while LID sessions remain. Because send encryption is LID-first, those stale LID sessions can continue to be selected after an identity reset, causing ongoing identity/session inconsistencies instead of a clean re-establishment.

Useful? React with 👍 / 👎.

record.devices.clear();

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 Preserve primary device on raw_id mismatch

Clearing record.devices here drops device 0 before the new list is rebuilt, but add notifications usually carry only the newly added companion. In that case the persisted registry can end up containing only the companion device, and later get_user_devices treats the registry as authoritative (cache hit path) and never refetches from usync, so group fanout can skip the user’s primary phone entirely. Keeping/rehydrating primary 0 after a raw-id reset avoids silently under-targeting recipients.

Useful? React with 👍 / 👎.

}
record.raw_id = Some(decoded.raw_id);

// Filter stale devices by valid_indexes
record.devices =
wacore::adv::filter_devices_by_key_index(&record.devices, &decoded);

// Only add the new device if its key_index is accepted by the ADV list
if !record.devices.iter().any(|d| d.device_id == device_id)
&& wacore::adv::is_key_index_valid(device.key_index, &decoded)
{
record.devices.push(wacore::store::traits::DeviceInfo {
device_id,
key_index: device.key_index,
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
warn!("patch_device_add: failed to decode key-index-list for user {user}");
self.append_device_if_new(&mut record, device_id, device.key_index);
}
} else {
// No signed bytes — fall back to simple append
self.append_device_if_new(&mut record, device_id, device.key_index);
}

if let Err(e) = self.update_device_list(record).await {
warn!("patch_device_add: failed to persist: {e}");
}
}

/// Append a device if it doesn't already exist in the record.
fn append_device_if_new(
&self,
record: &mut wacore::store::traits::DeviceListRecord,
device_id: u32,
key_index: Option<u32>,
) {
if !record.devices.iter().any(|d| d.device_id == device_id) {
record.devices.push(wacore::store::traits::DeviceInfo {
device_id,
key_index: device.key_index,
key_index,
});
if let Err(e) = self.update_device_list(record).await {
warn!("patch_device_add: failed to persist: {e}");
}
}

/// Clear device record on raw_id mismatch (identity change).
///
/// Matches WA Web's `clearDeviceRecord()` in `IdentityUpdateDeviceTableApi`:
/// - Deletes Signal sessions for non-primary devices (stale identity)
/// - Invalidates sender key device cache so SKDM will be redistributed
/// - Flushes cache to persist session deletions
pub(crate) async fn clear_device_record(
&self,
user: &str,
server: &str,
record: &wacore::store::traits::DeviceListRecord,
) {
let non_primary_count = record.devices.iter().filter(|d| d.device_id != 0).count();
info!(
"Clearing device record for user {user}: removing {non_primary_count} non-primary device(s) due to raw_id change",
);

// Delete Signal sessions under BOTH LID and PN addresses.
// The notification may arrive via one address but sessions can exist
// under either (encrypt path is LID-first, decrypt stores under sender).
let lookup = self.resolve_lookup_keys(user).await;
let servers: &[&str] = match &lookup {
UserLookupKeys::LidWithPn { .. } | UserLookupKeys::PnWithLid { .. } => &[
wacore_binary::jid::HIDDEN_USER_SERVER,
wacore_binary::jid::DEFAULT_USER_SERVER,
],
UserLookupKeys::Unknown { .. } => std::slice::from_ref(&server),
};
for &srv in servers {
for key in lookup.all_keys() {
for device in record.devices.iter().filter(|d| d.device_id != 0) {
let mut jid = Jid::new(key, srv);
jid.device = device.device_id as u16;
let addr = wacore::types::jid::JidExt::to_protocol_address(&jid);
self.signal_cache.delete_session(&addr).await;
}
}
}
if let Err(e) = self.flush_signal_cache().await {
warn!("clear_device_record: failed to flush session deletions: {e}");
}

// Clear persisted SKDM tracking across ALL groups so stale has_key=true
// rows don't survive restart. Identity changes are rare so the cost is acceptable.
if let Err(e) = self
.persistence_manager
.backend()
.clear_all_sender_key_devices()
.await
{
warn!("clear_device_record: failed to clear persisted sender key devices: {e}");
}
// Also invalidate in-memory cache
self.sender_key_device_cache.invalidate_all();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Remove a device from the registry after a device remove notification.
Expand Down Expand Up @@ -243,7 +360,7 @@ impl Client {
}

/// Load a `DeviceListRecord` from cache or DB for patching.
async fn load_device_record(
pub(crate) async fn load_device_record(
&self,
user: &str,
) -> Option<wacore::store::traits::DeviceListRecord> {
Expand Down Expand Up @@ -512,6 +629,7 @@ mod tests {
}],
timestamp: 12345,
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand Down Expand Up @@ -546,6 +664,7 @@ mod tests {
}],
timestamp: 12345,
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand All @@ -570,6 +689,7 @@ mod tests {
}],
timestamp: 12346,
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand Down Expand Up @@ -597,6 +717,7 @@ mod tests {
}],
timestamp: 12345,
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand Down Expand Up @@ -655,6 +776,7 @@ mod tests {
}],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand All @@ -663,7 +785,7 @@ mod tests {

// Patch: add device 3
let elem = make_device_element(3, Some(5));
client.patch_device_add("15551234567", &elem).await;
client.patch_device_add("15551234567", &elem, None).await;

let updated = client
.device_registry_cache
Expand All @@ -690,6 +812,7 @@ mod tests {
}],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand All @@ -698,7 +821,7 @@ mod tests {

// Patch: add device 3 again — should not duplicate
let elem = make_device_element(3, None);
client.patch_device_add("15551234567", &elem).await;
client.patch_device_add("15551234567", &elem, None).await;

let updated = client
.device_registry_cache
Expand All @@ -714,7 +837,7 @@ mod tests {

// No pre-populated cache — patch should be a no-op
let elem = make_device_element(3, None);
client.patch_device_add("15551234567", &elem).await;
client.patch_device_add("15551234567", &elem, None).await;

assert!(
client
Expand Down Expand Up @@ -745,6 +868,7 @@ mod tests {
],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand Down Expand Up @@ -781,6 +905,7 @@ mod tests {
],
timestamp: 1000,
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand Down Expand Up @@ -813,6 +938,7 @@ mod tests {
}],
timestamp: 1000,
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand All @@ -821,7 +947,7 @@ mod tests {

// Patch: add device 3
let elem = make_device_element(3, Some(2));
client.patch_device_add("15551234567", &elem).await;
client.patch_device_add("15551234567", &elem, None).await;

let updated = client
.device_registry_cache
Expand Down Expand Up @@ -857,6 +983,7 @@ mod tests {
],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.persistence_manager
Expand Down Expand Up @@ -917,6 +1044,7 @@ mod tests {
}],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.device_registry_cache
Expand Down Expand Up @@ -969,6 +1097,7 @@ mod tests {
}],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.persistence_manager
Expand All @@ -987,7 +1116,7 @@ mod tests {
);

let elem = make_device_element(3, Some(7));
client.patch_device_add("15551234567", &elem).await;
client.patch_device_add("15551234567", &elem, None).await;

// Verify patch was applied to DB (not silently dropped)
let updated = client
Expand Down Expand Up @@ -1030,6 +1159,7 @@ mod tests {
],
timestamp: wacore::time::now_secs(),
phash: None,
raw_id: None,
};
client
.persistence_manager
Expand Down
11 changes: 10 additions & 1 deletion src/handlers/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,9 @@ async fn handle_devices_notification(client: &Arc<Client>, node: &Node) {
match op.operation_type {
wacore::stanza::devices::DeviceNotificationType::Add => {
for device in &op.devices {
client.patch_device_add(notification.user(), device).await;
client
.patch_device_add(notification.user(), device, op.key_index.as_ref())
.await;
}
}
wacore::stanza::devices::DeviceNotificationType::Remove => {
Expand Down Expand Up @@ -487,6 +489,12 @@ async fn handle_account_sync_devices(client: &Arc<Client>, node: &Node, devices_
.map(|v| v as i64)
.unwrap_or_else(wacore::time::now_secs);

// Preserve existing raw_id so account_sync doesn't erase it
let existing_raw_id = client
.load_device_record(&from_jid.user)
.await
.and_then(|r| r.raw_id);

// Build DeviceListRecord for storage
// Note: update_device_list() will automatically store under LID if mapping is known
let device_list = DeviceListRecord {
Expand All @@ -500,6 +508,7 @@ async fn handle_account_sync_devices(client: &Arc<Client>, node: &Node, devices_
.collect(),
timestamp,
phash: dhash,
raw_id: existing_raw_id,
};

if let Err(e) = client.update_device_list(device_list).await {
Expand Down
Loading
Loading