Skip to content

fix(send): close SKDM flow gaps for forward secrecy and cache hygiene - #604

Merged
jlucaso1 merged 4 commits into
mainfrom
fix/skdm-flow-r1-r5
Apr 28, 2026
Merged

fix(send): close SKDM flow gaps for forward secrecy and cache hygiene#604
jlucaso1 merged 4 commits into
mainfrom
fix/skdm-flow-r1-r5

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Audit of the SKDM flow post-#603 surfaced five gaps versus WA Web. Each is fixed and covered by a regression test.

R1 — Forward secrecy on participant remove

handle_group_notification Remove and Groups::remove_participants only patched group_cache. WA Web's `removeParticipantInfo` (`GroupParticipantHelpers.js`) sets `rotateKey=true` whenever any removed participant had `has_key=true`, then `GroupSkmsgJob.js:238` calls `deleteGroupSenderKeyInfo` to drop the bot's own sender key before the next send. Without this rotation a kicked participant who already had the SKDM keeps decrypting future skmsg payloads using the cached sender key.

Fix: new `Client::rotate_sender_key_on_participant_remove` wired into both paths. Reads `sender_key_devices`, checks if any removed user had `has_key=true`, and if so deletes the bot's own sender key + clears the tracker so the next send takes `force_skdm=true` (`!key_exists`) and redistributes to remaining participants.

R2 — Sledgehammer invalidate_all() on device patches

`patch_device_add` and `patch_device_remove` were dropping every group's sender-key cache when any user's device set changed. WA Web's `updateGroupParticipantsInTransaction` mutates only the affected groups' `senderKey` Map.

Fix: drop both global invalidations. New devices are picked up automatically because `device_has_key()` returns `None` for entries not yet in the map → falls into `needs_skdm` on the next send.

R3 — Phash mismatch left persisted state stale

`spawn_phash_validation` only invalidated the in-memory cache; the next send reloaded the same stale rows from DB. Group sends also skipped the group_cache invalidation that status sends performed.

Fix: clear `sender_key_devices` for the chat on mismatch and invalidate `group_cache` for groups too. Next send takes the full-distribution path.

R4 — Orphan rows after patch_device_remove

The removed device's row in `sender_key_devices` was never deleted, accumulating dead state. WA Web (`UpdateParticipantApi.js:42`) does `senderKey.delete(deviceJid)` per affected group.

Fix: new `delete_sender_key_device_rows(device_jids)` backend method (Sqlite + InMemory + Persistence wrapper). `patch_device_remove` builds candidate JIDs under both LID and PN aliases via `resolve_lookup_keys` and deletes them.

R5 — No periodic sender-key rotation

WA Web has `EXPIRY_REASON.PERIODIC_ROTATION` in `WAWebWamEnumExpiryReason`; the threshold itself isn't visible in captured-js (likely server-pushed config). We never rotated, so long-running bots used the same sender key for months.

Fix: when a group send finds the existing sender key with `chain_key.iteration() ≥ 1000`, delete it and clear the tracker so the next send regenerates and full-distributes. Conservative default; can be adjusted if a real reference surfaces.

Tests

  • `participant_remove_rotates_sender_key_when_any_had_key` (R1)
  • `participant_remove_skips_rotation_when_none_had_key` (R1)
  • `patch_device_add_preserves_unrelated_group_caches` (R2)
  • `patch_device_remove_preserves_unrelated_group_caches` (R2)
  • `patch_device_remove_clears_sender_key_device_rows` (R4)
  • `test_patch_device_add_keeps_cache_warm_new_device_seen_as_unknown` (R2 update of an existing test)
  • `test_patch_device_remove_clears_row_and_keeps_others_warm` (R4 update of an existing test)

R3 is implicitly covered by `resolve_skdm_targets_distributes_when_cache_empty_but_devices_known` from #603 — the phash fix calls `clear_sender_key_devices` whose post-condition is the same empty-cache state.

R5 has no unit test because the threshold check is inline in `send_message_impl` and exercising it requires a connected client; manually validated by code review.

Test plan

  • `cargo test --workspace --lib` — all green (442 in main crate, ~1370 total)
  • `cargo clippy --all --tests` — clean
  • `cargo fmt --all`
  • Production validation: bot kicks a member from a group it manages, confirms next reply lands without retry receipts and that the kicked member can no longer decrypt.

Five WA Web alignment issues found while auditing the post-PR-#603 SKDM
flow. Each had an empirical regression test before being fixed.

R1 — Forward secrecy on participant remove
   `handle_group_notification` Remove and `Groups::remove_participants`
   only patched `group_cache`. WA Web's `removeParticipantInfo`
   (`GroupParticipantHelpers.js`) sets `rotateKey=true` whenever any
   removed participant had `has_key=true`, then `GroupSkmsgJob.js:238`
   calls `deleteGroupSenderKeyInfo` to drop the bot's own sender key
   before the next send.
   Fix: new `Client::rotate_sender_key_on_participant_remove` helper
   wired into both paths. Reads `sender_key_devices`, checks if any
   removed user had `has_key=true`, and if so deletes the bot's own
   sender key + clears the group's tracker so the next send takes
   `force_skdm=true` (`!key_exists`) and redistributes to remaining
   participants.

R2 — `patch_device_add` / `patch_device_remove` `invalidate_all()`
   The previous code dropped every group's sender-key cache when any
   user's device set changed. WA Web mutates only the affected groups'
   `senderKey` Map (`UpdateParticipantApi.js`).
   Fix: drop the global invalidation. Unknown new devices are picked up
   automatically because `device_has_key()` returns `None` for entries
   not yet in the map → falls into `needs_skdm` on the next send.

R3 — Phash mismatch left persisted state stale
   `spawn_phash_validation` only invalidated the in-memory cache;
   reload from DB surfaced the same stale rows. Group sends also
   skipped the group_cache invalidation that status sends performed.
   Fix: clear `sender_key_devices` for the chat on mismatch and
   invalidate `group_cache` for groups too.

R4 — Orphan `sender_key_devices` rows after `patch_device_remove`
   The removed device's row was never deleted, accumulating dead state.
   Fix: new `delete_sender_key_device_rows(device_jids)` backend method
   (Sqlite + InMemory + Persistence wrapper). `patch_device_remove`
   builds candidate JIDs under both LID and PN aliases via
   `resolve_lookup_keys` and deletes them.

R5 — No periodic sender-key rotation
   WA Web has `EXPIRY_REASON.PERIODIC_ROTATION` in the WAM enum;
   captured-js doesn't surface the threshold. We never rotated.
   Fix: when a group send finds the existing sender key with
   `chain_key.iteration() ≥ 1000`, delete it and clear the tracker so
   the next send regenerates and full-distributes. Conservative
   default; can be adjusted if a WA Web reference surfaces.
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Automatic sender-key rotation when participants are removed from groups.
    • Periodic sender-key rotation based on key-iteration thresholds to force redistributions.
    • New persisted operation to delete sender-key device rows in bulk.
  • Bug Fixes

    • Targeted device-level cache invalidation and persisted-key row cleanup on device removal to keep unrelated groups warm.
    • Abort on DB cleanup failure to avoid partial registry updates; improved persisted-state cleanup and fallback on validation mismatches.
  • Tests

    • Updated mocks and tests to cover device-row deletion and sender-key rotation scenarios.

Walkthrough

Targeted per-(user,device) sender-key-device invalidation replaces global wipes, adds a persistence API to delete sender_key_devices rows by device JID, and triggers sender-key rotation when accepted group participants are removed. Storage backends and tests updated to support batched deletion.

Changes

Cohort / File(s) Summary
Sender-Key Rotation & Handlers
src/client/sender_keys.rs, src/features/groups.rs, src/handlers/notification.rs
Adds rotate_sender_key_on_participant_remove on Client and invokes it after accepted participant removals; reads persisted sender_key_devices, conditionally deletes bot sender keys from signal cache, clears persisted sender_key_devices for the group, and invalidates device cache.
Device Registry & Cache Behavior
src/client/device_registry.rs, src/sender_key_device_cache.rs
patch_device_remove now deletes per-device sender_key_devices DB rows and evicts only affected in-memory entries; removes global invalidate_all() and adds invalidate_entries_for_device for targeted invalidation.
Send Path & Phash Handling
src/send.rs
Phash-mismatch handling now attempts persisted sender_key_devices cleanup and falls back to signal_cache flush; adds periodic sender-key rotation detection (iteration threshold) that triggers persisted+in-memory cleanup and forces redistribution semantics.
Persistence API & Manager
src/store/persistence_manager.rs, wacore/src/store/persistence.rs, wacore/src/store/traits.rs
Adds delete_sender_key_device_rows(&[&str]) to ProtocolStore trait and PersistenceManager, forwarding to backend.
Storage Backends
storages/sqlite-storage/src/sqlite_store.rs, wacore/src/store/in_memory.rs
Implements delete_sender_key_device_rows in SQLite (batched, chunked deletes with retry) and in-memory backend (mutex-protected pruning of sender_key_devices).
Tests / Mocks
src/appstate_sync.rs
Test mock ProtocolStore extended with a no-op delete_sender_key_device_rows to satisfy trait change.

Sequence Diagram(s)

sequenceDiagram
    participant GroupHandler as Group Handler
    participant Client as Client (sender_keys)
    participant Store as Persistence Store
    participant SignalCache as Signal Cache
    participant DeviceCache as Device Cache

    GroupHandler->>Client: rotate_sender_key_on_participant_remove(group_jid, removed_user_ids)
    Client->>Store: read sender_key_devices for group_jid
    Store-->>Client: sender_key_devices rows

    alt any removed had has_key = true
        Client->>SignalCache: delete sender key for group_jid
        Client->>SignalCache: flush cached keys
        Client->>Store: delete_sender_key_device_rows(device_jids_for_group)
        Store-->>Client: deletion result
        Client->>DeviceCache: invalidate_entries_for_device(user, device_id) for affected devices
    else none had has_key (read succeeded)
        Client-->>GroupHandler: no rotation required
    end

    Client-->>GroupHandler: rotation complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Ari4ka
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main objective: fixing SKDM flow gaps affecting forward secrecy and cache hygiene, which aligns with the substantial changes across device handling, sender key rotation, and persistence logic.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing five specific gaps (R1–R5) with their fixes, test coverage, and validation status, all of which correspond to the actual code modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/skdm-flow-r1-r5

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 378-402: delete_sender_key_rows_for_device currently deletes DB
rows but does not evict warmed in-memory sender-key state for groups that
indexed those JIDs; after calling
self.persistence_manager.delete_sender_key_device_rows(&refs).await you must
invalidate the in-memory cache for those JIDs (use the JID strings in
candidates/refs). If there is an existing cache helper, call it (e.g.
self.invalidate_groups_indexing_jids(&refs) or
self.sender_key_cache.invalidate_by_jids(&refs)); otherwise add a small helper
method on the same impl (e.g. invalidate_groups_for_jids(&[&str])) that clears
per-group entries referencing those JIDs and call it after the DB delete; also
add a regression test that warms the group cache for a specific group, calls
delete_sender_key_rows_for_device(user, device_id) and then asserts that
subsequent sender-key lookups for that group do not return has_key for the
removed JID.

In `@src/client/sender_keys.rs`:
- Around line 85-97: The error handling in
rotate_sender_key_on_participant_remove currently returns early on
persistence_manager.get_sender_key_devices failure, leaving the old sender key
in place; change the Err(e) branch to log the error but not return — instead
proceed with rotation (e.g., treat rows as empty or otherwise continue into the
rotation path) so the function always performs key rotation after a participant
removal; update the Err branch that references
persistence_manager.get_sender_key_devices and
rotate_sender_key_on_participant_remove to log the error and continue rather
than returning.

In `@src/send.rs`:
- Around line 699-709: The current code logs and continues when
client.persistence_manager.clear_sender_key_devices(&jid_str).await fails, which
leaves persistent state intact and allows reload of stale rows; instead, when
clear_sender_key_devices returns Err(e) you must prevent fallback to stale
persisted state by propagating the error (return Err) or otherwise forcing a
full-distribution path (e.g., set a force_full_distribution flag) rather than
only invalidating client.sender_key_device_cache; update the branch around
clear_sender_key_devices / sender_key_device_cache.invalidate to either return
the error from this function or set a concrete indicator on client so subsequent
sends cannot reload the stale DB rows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c2245f0d-6493-4482-a326-3a7b14aa9c5d

📥 Commits

Reviewing files that changed from the base of the PR and between 59b2e25 and 79ce0b8.

📒 Files selected for processing (12)
  • src/appstate_sync.rs
  • src/client/device_registry.rs
  • src/client/sender_keys.rs
  • src/features/groups.rs
  • src/handlers/notification.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • src/store/persistence_manager.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/persistence.rs
  • wacore/src/store/traits.rs
💤 Files with no reviewable changes (1)
  • src/sender_key_device_cache.rs

Comment thread src/client/device_registry.rs
Comment thread src/client/sender_keys.rs Outdated
Comment thread src/send.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79ce0b8ce9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +387 to +391
for server in servers {
for key in lookup.all_keys() {
let mut jid = Jid::new(key, server);
jid.device = device_id as u16;
candidates.push(jid.to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid deleting sender-key rows for mixed PN/LID combos

delete_sender_key_rows_for_device builds candidate JIDs as a Cartesian product of lookup.all_keys() and both servers, which generates impossible identities like pn@lid and lid@s.whatsapp.net. Because delete_sender_key_device_rows deletes across all groups, a numeric collision with another account can remove unrelated sender-key tracking rows and force unnecessary redistributions. Deletion should be limited to valid namespace pairs for mapped users (and only broadened when user type is truly unknown).

Useful? React with 👍 / 👎.

Comment thread src/send.rs Outdated
Comment on lines +703 to +706
if (jid.is_group() || jid.is_status_broadcast())
&& let Err(e) =
client.persistence_manager.clear_sender_key_devices(&jid_str).await
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make phash-mismatch DB clear win over post-send updates

This mismatch handler clears sender_key_devices in a spawned task, but status sends still call update_sender_key_devices immediately after spawning validation. If the ACK is already available and this task runs first, that later update can repopulate the stale rows that were just cleared, so the next send may skip the intended full redistribution after a mismatch. The mismatch clear needs deterministic ordering against post-send row updates.

Useful? React with 👍 / 👎.

- patch_device_remove now also evicts cached has_key entries for the
  removed (user, device_id). A re-add of the same device_id would
  otherwise hit the stale `has_key=true` entry and skip SKDM
  redistribution. New helper `SenderKeyDeviceCache::invalidate_entries_for_device`.

- rotate_sender_key_on_participant_remove rotates conservatively when
  the `sender_key_devices` read fails, instead of returning early. Better
  to pay the redistribute cost than leave the sender key in place after
  a remove we couldn't audit.

- phash mismatch handler now falls back to deleting the bot's own sender
  key when `clear_sender_key_devices` fails. With the persisted state
  potentially stale, the next send is forced through `force_skdm=true`
  via `!key_exists` instead of trusting the in-memory invalidation alone.

- New regression `patch_device_remove_evicts_cached_has_key_for_removed_device`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 396-402: The delete path currently swallows errors from
persistence_manager.delete_sender_key_device_rows (logged with warn!) which can
leave stale has_key=true rows; change this to fail closed by propagating the
error instead of only logging: replace the warn! branch in the
delete_sender_key_rows_for_device flow so that on Err(e) you return Err(e) (or
map it into the surrounding function's error type) so callers know deletion
failed and can retry/abort redistribution; ensure you update the surrounding
function's signature/propagation (the call site of
persistence_manager.delete_sender_key_device_rows and the enclosing
delete_sender_key_rows_for_device logic) to propagate the error instead of
continuing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bf9fc505-9a79-45ff-82a5-177665a24a57

📥 Commits

Reviewing files that changed from the base of the PR and between 79ce0b8 and b4fdad2.

📒 Files selected for processing (4)
  • src/client/device_registry.rs
  • src/client/sender_keys.rs
  • src/send.rs
  • src/sender_key_device_cache.rs

Comment thread src/client/device_registry.rs Outdated
Comment on lines +396 to +402
if let Err(e) = self
.persistence_manager
.delete_sender_key_device_rows(&refs)
.await
{
warn!("delete_sender_key_rows_for_device: {e}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fail closed when sender-key row deletion fails.

At Line 396-402, we only log on delete failure. That leaves stale persisted has_key=true rows; a future re-add of the same device ID can reload stale state and skip redistribution.

Suggested conservative fallback
         if let Err(e) = self
             .persistence_manager
             .delete_sender_key_device_rows(&refs)
             .await
         {
             warn!("delete_sender_key_rows_for_device: {e}");
+            // Conservative fallback: avoid retaining stale SKDM tracker rows.
+            if let Err(e2) = self.persistence_manager.clear_all_sender_key_devices().await {
+                warn!(
+                    "delete_sender_key_rows_for_device: fallback clear_all_sender_key_devices failed: {e2}"
+                );
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 396 - 402, The delete path
currently swallows errors from persistence_manager.delete_sender_key_device_rows
(logged with warn!) which can leave stale has_key=true rows; change this to fail
closed by propagating the error instead of only logging: replace the warn!
branch in the delete_sender_key_rows_for_device flow so that on Err(e) you
return Err(e) (or map it into the surrounding function's error type) so callers
know deletion failed and can retry/abort redistribution; ensure you update the
surrounding function's signature/propagation (the call site of
persistence_manager.delete_sender_key_device_rows and the enclosing
delete_sender_key_rows_for_device logic) to propagate the error instead of
continuing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4fdad27fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/sender_keys.rs
Comment on lines +103 to +107
*has_key
&& jid_str
.parse::<Jid>()
.ok()
.is_some_and(|jid| removed_user_ids.iter().any(|u| *u == jid.user.as_str()))

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 👍 / 👎.

`delete_sender_key_rows_for_device` now returns `Result<(), StoreError>`
instead of swallowing the persistence error with `warn!`. Cache eviction
runs only on success.

`patch_device_remove` aborts the registry update when the sender-key
cleanup fails — a half-applied state where `resolve_devices` reports
"gone" but the tracker still vouches `has_key=true` would silently skip
SKDM redistribution and leave forward-secrecy gaps the rotation paths
were designed to close.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/device_registry.rs (1)

358-365: ⚠️ Potential issue | 🟠 Major

Guard the u32 -> u16 downcast before cleanup.

device_id is a u32, but both session deletion and sender-key cleanup silently truncate it to u16. If a bad notification or corrupted record ever carries > u16::MAX, this path cleans up the wrong device and can wipe another device's session/SKDM state.

Proposed fix
 pub(crate) async fn patch_device_remove(&self, user: &str, device_id: u32) {
+    let Ok(device_id_u16) = u16::try_from(device_id) else {
+        warn!("patch_device_remove: device_id {device_id} exceeds u16::MAX; aborting");
+        return;
+    };
+
     if let Some(mut record) = self.load_device_record(user).await {
         let before = record.devices.len();
         record.devices.retain(|d| d.device_id != device_id);
         if record.devices.len() != before {
             if device_id != 0 {
-                self.delete_sessions_for_devices(user, &[device_id as u16])
+                self.delete_sessions_for_devices(user, &[device_id_u16])
                     .await;
             }
@@
                 if let Err(e) = self
-                    .delete_sender_key_rows_for_device(user, device_id)
+                    .delete_sender_key_rows_for_device(user, device_id_u16)
                     .await
                 {
     async fn delete_sender_key_rows_for_device(
         &self,
         user: &str,
-        device_id: u32,
+        device_id: u16,
     ) -> Result<(), wacore::store::error::StoreError> {
@@
                 let mut jid = Jid::new(key, server);
-                jid.device = device_id as u16;
+                jid.device = device_id;
                 candidates.push(jid.to_string());
@@
         for key in lookup.all_keys() {
             self.sender_key_device_cache
-                .invalidate_entries_for_device(key, device_id as u16)
+                .invalidate_entries_for_device(key, device_id)
                 .await;
         }

Also applies to: 399-423

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 358 - 365, The removal path in
patch_device_remove performs a silent downcast from u32 device_id to u16 when
calling delete_sessions_for_devices (and similarly where sender-key cleanup is
done), which can truncate ids > u16::MAX and delete the wrong device data;
update patch_device_remove to check that device_id <= u16::MAX before
converting, and if it exceeds u16::MAX skip session/SKDM cleanup (or return an
error/log and still persist the device removal) so you never cast blindly; apply
the same guarded conversion logic to the other cleanup site referenced (the
similar block around lines 399-423) so all u32->u16 downcasts use a checked
guard or explicit handling instead of unchecked truncation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 278-280: Update the method docs for patch_device_add to reflect
current behavior: state that new devices are not causing global sender-key
device cache invalidation because resolve_skdm_targets will automatically pick
up unknown devices (device_has_key() returns None and falls into needs_skdm).
Edit the doc comment above the patch_device_add function to remove or replace
any text claiming it invalidates the sender-key device cache and instead
describe the new automatic resolution flow (reference resolve_skdm_targets,
device_has_key, needs_skdm and the sender-key device cache to make the intent
explicit).

---

Outside diff comments:
In `@src/client/device_registry.rs`:
- Around line 358-365: The removal path in patch_device_remove performs a silent
downcast from u32 device_id to u16 when calling delete_sessions_for_devices (and
similarly where sender-key cleanup is done), which can truncate ids > u16::MAX
and delete the wrong device data; update patch_device_remove to check that
device_id <= u16::MAX before converting, and if it exceeds u16::MAX skip
session/SKDM cleanup (or return an error/log and still persist the device
removal) so you never cast blindly; apply the same guarded conversion logic to
the other cleanup site referenced (the similar block around lines 399-423) so
all u32->u16 downcasts use a checked guard or explicit handling instead of
unchecked truncation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9cc7b2cd-f4ce-498e-9d88-3f9ed14838c2

📥 Commits

Reviewing files that changed from the base of the PR and between b4fdad2 and 01b1859.

📒 Files selected for processing (1)
  • src/client/device_registry.rs

Comment thread src/client/device_registry.rs
…_add doc

`patch_device_remove` now uses `u16::try_from` before passing the device
id to JID-keyed cleanup paths. Out-of-range ids are logged and the
registry update is still applied so `resolve_devices` stops returning
the device — blind `as u16` would otherwise truncate to a different id
and clean up the wrong device's sessions/sender-key rows. The inner
helper takes `u16` directly so the bounds check happens once at the
entry point.

`patch_device_add`'s doc comment now describes the post-PR-#604 flow
(automatic resolution via `resolve_skdm_targets` / `device_has_key`)
instead of claiming a global cache invalidation the body no longer does.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/client/device_registry.rs (1)

388-397: ⚠️ Potential issue | 🟠 Major

Don’t silently drop remove events on cleanup failure — bubble an error to the caller.

Right now this just logs + returns. If cleanup fails transiently, the device removal can be lost with no retry signal. We need deterministic failure propagation on this path.

Suggested fix direction
-pub(crate) async fn patch_device_remove(&self, user: &str, device_id: u32) {
+pub(crate) async fn patch_device_remove(&self, user: &str, device_id: u32) -> Result<()> {
@@
-                if let Err(e) = self
-                    .delete_sender_key_rows_for_device(user, device_id_u16)
-                    .await
-                {
-                    warn!(
-                        "patch_device_remove: sender-key cleanup failed for {user}:{device_id}: {e} \
-                         — aborting registry update"
-                    );
-                    return;
-                }
+                self
+                    .delete_sender_key_rows_for_device(user, device_id_u16)
+                    .await
+                    .map_err(|e| anyhow::anyhow!(
+                        "patch_device_remove: sender-key cleanup failed for {user}:{device_id}: {e}"
+                    ))?;
@@
-                    return;
+                    return Ok(());
                 };
@@
-}
+    Ok(())
+}

Also update call sites (e.g., notification handler) to handle Result explicitly and apply retry/backoff policy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/device_registry.rs` around lines 388 - 397, The
patch_device_remove path currently swallows errors from
delete_sender_key_rows_for_device by logging and returning; change
patch_device_remove to return a Result (propagate the error) instead of unit,
and replace the bare return with Err(e) so failures bubble to callers; update
delete_sender_key_rows_for_device call sites (e.g., the notification handler) to
handle the Result explicitly and implement retry/backoff or propagate further so
transient cleanup failures are retried or surfaced to the caller; ensure
function signatures (patch_device_remove and any callers) and error types align
to compile.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/client/device_registry.rs`:
- Around line 388-397: The patch_device_remove path currently swallows errors
from delete_sender_key_rows_for_device by logging and returning; change
patch_device_remove to return a Result (propagate the error) instead of unit,
and replace the bare return with Err(e) so failures bubble to callers; update
delete_sender_key_rows_for_device call sites (e.g., the notification handler) to
handle the Result explicitly and implement retry/backoff or propagate further so
transient cleanup failures are retried or surfaced to the caller; ensure
function signatures (patch_device_remove and any callers) and error types align
to compile.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9d25b90d-bc45-4810-bd1b-e141448132f8

📥 Commits

Reviewing files that changed from the base of the PR and between 01b1859 and ae0ab11.

📒 Files selected for processing (1)
  • src/client/device_registry.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae0ab110e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/sender_keys.rs
Comment on lines +109 to +110
if !read_failed && !any_had_key {
return;

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 👍 / 👎.

@jlucaso1
jlucaso1 merged commit 4205ca8 into main Apr 28, 2026
12 of 13 checks passed
@jlucaso1
jlucaso1 deleted the fix/skdm-flow-r1-r5 branch April 28, 2026 18:39
jlucaso1 added a commit that referenced this pull request Apr 29, 2026
The default Cache type alias falls through to PortableCache when the
moka-cache feature is disabled. PR #604 added invalidate_entries_for_device
to SenderKeyDeviceCache, which calls self.inner.iter() — moka has it,
PortableCache didn't. Builds with --no-default-features broke at
src/sender_key_device_cache.rs:93 with "no method named iter".

Snapshot the live entries under a brief read lock (same try_read + spin
pattern used by entry_count and invalidate_all) and return an owned
Vec::IntoIter of (Arc<K>, V), matching moka's iter signature for the
call site. The eager snapshot frees the lock before the caller starts
iterating, so callers can mutate the cache mid-iteration without
deadlock.
jlucaso1 added a commit that referenced this pull request Apr 29, 2026
The default Cache type alias falls through to PortableCache when the
moka-cache feature is disabled. PR #604 added invalidate_entries_for_device
to SenderKeyDeviceCache, which calls self.inner.iter() — moka has it,
PortableCache didn't. Builds with --no-default-features broke at
src/sender_key_device_cache.rs:93 with "no method named iter".

Snapshot the live entries under a brief read lock (same try_read + spin
pattern used by entry_count and invalidate_all) and return an owned
Vec::IntoIter of (Arc<K>, V), matching moka's iter signature for the
call site. The eager snapshot frees the lock before the caller starts
iterating, so callers can mutate the cache mid-iteration without
deadlock.
jlucaso1 added a commit that referenced this pull request Apr 29, 2026
The default Cache type alias falls through to PortableCache when the
moka-cache feature is disabled. PR #604 added invalidate_entries_for_device
to SenderKeyDeviceCache, which calls self.inner.iter() — moka has it,
PortableCache didn't. Builds with --no-default-features broke at
src/sender_key_device_cache.rs:93 with "no method named iter".

Snapshot the live entries under a brief read lock (same try_read + spin
pattern used by entry_count and invalidate_all) and return an owned
Vec::IntoIter of (Arc<K>, V), matching moka's iter signature for the
call site. The eager snapshot frees the lock before the caller starts
iterating, so callers can mutate the cache mid-iteration without
deadlock.
jlucaso1 added a commit that referenced this pull request Apr 29, 2026
The default Cache type alias falls through to PortableCache when the
moka-cache feature is disabled. PR #604 added invalidate_entries_for_device
to SenderKeyDeviceCache, which calls self.inner.iter() — moka has it,
PortableCache didn't. Builds with --no-default-features broke at
src/sender_key_device_cache.rs:93 with "no method named iter".

Snapshot the live entries under a brief read lock (same try_read + spin
pattern used by entry_count and invalidate_all) and return an owned
Vec::IntoIter of (Arc<K>, V), matching moka's iter signature for the
call site. The eager snapshot frees the lock before the caller starts
iterating, so callers can mutate the cache mid-iteration without
deadlock.
JeanCapixaba pushed a commit to JeanCapixaba/whatsapp-rust that referenced this pull request Jun 18, 2026
…pos-oxidezap#604)

Emite Event::ServerAck { id } em handle_ack_response pra TODO <ack> do servidor
com id, em adicao (nao no lugar) a resolucao do waiter interno. Permite medir
recebido->aceite-do-servidor sem tocar no fluxo de envio/phash. EventKind no fim
(nao desloca discriminantes); on_event do consumidor usa EventInterest::ALL.
JeanCapixaba pushed a commit to JeanCapixaba/whatsapp-rust that referenced this pull request Jun 30, 2026
…pos-oxidezap#604)

Emite Event::ServerAck { id } em handle_ack_response pra TODO <ack> do servidor
com id, em adicao (nao no lugar) a resolucao do waiter interno. Permite medir
recebido->aceite-do-servidor sem tocar no fluxo de envio/phash. EventKind no fim
(nao desloca discriminantes); on_event do consumidor usa EventInterest::ALL.
JeanCapixaba pushed a commit to JeanCapixaba/whatsapp-rust that referenced this pull request Jul 1, 2026
…pos-oxidezap#604)

Emite Event::ServerAck { id } em handle_ack_response pra TODO <ack> do servidor
com id, em adicao (nao no lugar) a resolucao do waiter interno. Permite medir
recebido->aceite-do-servidor sem tocar no fluxo de envio/phash. EventKind no fim
(nao desloca discriminantes); on_event do consumidor usa EventInterest::ALL.
JeanCapixaba pushed a commit to JeanCapixaba/whatsapp-rust that referenced this pull request Jul 6, 2026
…pos-oxidezap#604)

Emite Event::ServerAck { id } em handle_ack_response pra TODO <ack> do servidor
com id, em adicao (nao no lugar) a resolucao do waiter interno. Permite medir
recebido->aceite-do-servidor sem tocar no fluxo de envio/phash. EventKind no fim
(nao desloca discriminantes); on_event do consumidor usa EventInterest::ALL.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant