Skip to content
Closed
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
46 changes: 39 additions & 7 deletions advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -616,14 +616,44 @@ pub(crate) struct SenderKeyDeviceMap {
| `NoSenderKeyState` error during send | Invalidate group entry |
| Retry failure for a group message | Invalidate group entry |
| Server rejects group stanza | Invalidate group entry |
| New device added (`patch_device_add`) | Invalidate all entries |
| Device removed (`patch_device_remove`) | Invalidate all entries |
| Phash mismatch on group/status ACK | Clear persisted `sender_key_devices` rows for the chat **and** invalidate the in-memory group entry. If the DB clear fails, fall back to deleting the bot's own sender key for the chat so the next send hits `!key_exists` and force-distributes without relying on the tracker. |
| Periodic rotation (chain iteration ≥ 1000) | Delete the bot's own sender key, clear `sender_key_devices` for the chat, and invalidate the group entry. Mirrors WhatsApp Web's `SenderKeyExpired(PERIODIC_ROTATION)`. |
| Participant removed from group | If any removed participant had `has_key=true`, delete the bot's own sender key for the group, clear `sender_key_devices`, and invalidate the group entry. See [Forward-secrecy rotation on participant removal](#forward-secrecy-rotation-on-participant-removal). |
| New device added (`patch_device_add`) | No invalidation — `device_has_key()` returns `None` for unseen device ids, dropping them into `needs_skdm` on the next send. |
| Device removed (`patch_device_remove`) | Delete `sender_key_devices` rows for the removed JID across all groups (mirrors WA Web's `senderKey.delete(deviceJid)`), then evict only those cache entries indexing `(user, device_id)`. Unrelated group caches stay warm. The DB delete must succeed before the registry update is persisted, otherwise the registry would say "gone" while the tracker still vouched `has_key=true`. |
| Identity change (`clear_device_record`) | No global tracker wipe — per-device SKDM redistribution is driven by retry receipts (`markForgetSenderKey`), matching WhatsApp Web's `WAWebUpdateLocalSignalSession`. The `status@broadcast` sender key is still deleted for forward secrecy. |

You can tune the cache capacity and TTI via the `sender_key_devices_cache` field in [`CacheConfig`](/api/bot#cache-configuration-reference).

Location: `src/sender_key_device_cache.rs`, `src/send.rs`

### Forward-secrecy rotation on participant removal

When participants are removed from a group — either via the local `update_group_participants` API or via an inbound group remove notification — the client mirrors WhatsApp Web's `removeParticipantInfo` (`GroupParticipantHelpers.js`) by calling `rotate_sender_key_on_participant_remove`:

1. Reads the group's `sender_key_devices` rows.
2. Checks whether **any** removed user had `has_key=true` for any of their devices.
3. If yes, deletes the bot's own sender key for the group (across both LID and PN own-JIDs), clears `sender_key_devices` for the group, and invalidates the in-memory cache entry.
4. The next group send sees `!key_exists` and takes the `force_skdm=true` path, redistributing a fresh sender key to all remaining participants — so the removed user can no longer decrypt forward traffic with the old key.

When none of the removed users had `has_key=true`, no rotation occurs — they never received an SKDM, so there is nothing for them to decrypt forward and the redistribute cost is skipped.

If the `sender_key_devices` read fails, the client rotates conservatively (treats the unaudited state as "removed user may have had a key") rather than leaving the old key in place.

Location: `src/client/sender_keys.rs`, `src/features/groups.rs`, `src/handlers/notification.rs`

### Periodic sender key rotation

Long-lived sender keys advance their chain every group send. To match WhatsApp Web's `SenderKeyExpired(PERIODIC_ROTATION)` posted after a chain crosses a threshold, the client rotates when the sender chain iteration reaches **1000**:

1. The existence check loads the current `SenderKeyRecord` and inspects the sender chain key iteration.
2. When `iteration >= 1000`, the bot's own sender key is deleted, `sender_key_devices` is cleared for the chat, and the in-memory cache entry is invalidated.
3. The send proceeds with `force_skdm=true`, redistributing a fresh sender key to all participants.

The threshold value (1000) is not exposed by WA Web's captured-js sources; it mirrors common Signal hygiene defaults. Periodic rotation runs inline on the send path so latency is paid by the rotating message, not by every subsequent message.

Location: `src/send.rs`

### Phash validation for stale device list detection

When sending group, status, or DM messages, the library validates the participant hash (`phash`) returned in the server's acknowledgment against the locally computed `phash`. A mismatch indicates that the server's view of participant devices differs from the client's — meaning the local device list is stale.
Expand All @@ -638,11 +668,13 @@ When sending group, status, or DM messages, the library validates the participan

**On mismatch, the following caches are invalidated:**

| Send path | Sender key device cache | Group info cache | Device registry |
|-----------|------------------------|-----------------|-----------------|
| Group messages | Invalidated | Invalidated | — |
| Status messages | Invalidated | Not invalidated | — |
| DM messages | — | — | Recipient + own PN devices invalidated |
| Send path | Persisted `sender_key_devices` | In-memory sender key device cache | Group info cache | Device registry |
|-----------|-------------------------------|-----------------------------------|------------------|-----------------|
| Group messages | Cleared for the chat | Invalidated | Invalidated | — |
| Status messages | Cleared for `status@broadcast` | Invalidated | Not invalidated | — |
| DM messages | — | — | — | Recipient + own PN devices invalidated |

For group and status sends, cache-only invalidation would re-read the same stale rows from the database on the next send, so the client also clears the persisted `sender_key_devices` state for the chat. If that DB clear fails, the client falls back to deleting the bot's own sender key for the chat — the next send then sees `!key_exists`, takes the `force_skdm=true` path, and redistributes without depending on the tracker.

For DM messages, the phash covers both recipient and own devices (matching WA Web's `syncDeviceListJob([recipient, me])`). On mismatch, the client invalidates the device registry cache for both the recipient's user JID and your own phone number (PN) JID, ensuring the next send re-fetches the current device list for both parties.

Expand Down
4 changes: 2 additions & 2 deletions api/send.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -586,8 +586,8 @@ client.unpin_message(chat_jid, key).await?;

When sending group, status, or DM messages, the library automatically validates the participant hash (`phash`) from the server's acknowledgment against the locally computed value. If the hashes differ, the appropriate caches are invalidated so the next send re-fetches current participant devices from the server:

- **Group messages**: sender key device cache and group info cache are invalidated
- **Status messages**: sender key device cache is invalidated
- **Group messages**: persisted `sender_key_devices` rows for the chat are cleared, the in-memory sender key device cache and group info cache are invalidated. If the persisted clear fails, the client falls back to deleting the bot's own sender key for the chat so the next send hits `!key_exists` and force-redistributes
- **Status messages**: persisted `sender_key_devices` rows for `status@broadcast` are cleared and the in-memory sender key device cache is invalidated (same fallback applies)
- **DM messages**: the device registry cache is invalidated for both the recipient and your own phone number (PN), matching WA Web's `syncDeviceListJob([recipient, me])` behavior

For DMs, the phash is computed locally from the sent device set but is **not** sent on the wire (WA Web only sends phash for groups). The phash is returned via the `PreparedDmStanza.phash` field and compared against the server's ACK phash.
Expand Down
2 changes: 1 addition & 1 deletion api/status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -354,4 +354,4 @@ The `recipients` list should match your privacy settings. When revoking a status

## Phash validation

After sending a status update, the library validates the participant hash (`phash`) from the server's acknowledgment against the locally computed value. On mismatch, the sender key device cache is invalidated so the next status send re-fetches current device lists. This runs in the background and does not affect the send result. See [Signal Protocol — Phash validation](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection) for details.
After sending a status update, the library validates the participant hash (`phash`) from the server's acknowledgment against the locally computed value. On mismatch, the persisted `sender_key_devices` rows for `status@broadcast` are cleared and the in-memory sender key device cache is invalidated so the next status send re-fetches current device lists and force-redistributes. If the DB clear fails, the bot's own status sender key is deleted as a fallback. This runs in the background and does not affect the send result. See [Signal Protocol — Phash validation](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection) for details.
6 changes: 6 additions & 0 deletions api/store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ async fn set_sender_key_status(&self, group_jid: &str, entries: &[(&str, bool)])

/// 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 WhatsApp Web's per-group `senderKey.delete(deviceJid)` cleanup.
/// Called when a device is removed from a user's registry so a future re-add
/// of the same device id cannot hit a stale `has_key=true` entry and skip SKDM.
async fn delete_sender_key_device_rows(&self, device_jids: &[&str]) -> Result<()>;
```

### LID-PN Mapping
Expand Down
5 changes: 4 additions & 1 deletion concepts/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1458,7 +1458,10 @@ pub struct DeviceNotificationInfo {
| `key_index` | ADV key index info for device identity verification (add operations only) |
| `contact_hash` | Server-side contact hash for update operations |

This event is dispatched after the client has already patched its internal device registry cache. You can use it to track when contacts pair or unpair companion devices. The client also uses device list changes internally to manage [unknown device detection](/advanced/signal-protocol#unknown-device-detection), Signal session cleanup, and sender key cache invalidation — when a device is added or removed, the sender key device cache is invalidated so SKDM is redistributed on the next group message.
This event is dispatched after the client has already patched its internal device registry cache. You can use it to track when contacts pair or unpair companion devices. The client also uses device list changes internally to manage [unknown device detection](/advanced/signal-protocol#unknown-device-detection) and Signal session cleanup. SKDM redistribution is driven per-group/per-device by the resolver:

- **Device added** — no cache wipe is performed. `device_has_key()` returns `None` for the unseen device id, so it falls into `needs_skdm` automatically on the next group send.
- **Device removed** — the matching `sender_key_devices` rows for the device JID are deleted (mirroring WhatsApp Web's `senderKey.delete(deviceJid)`), and any cached map indexing that `(user, device_id)` pair is evicted so a future re-add of the same device id cannot hit a stale `has_key=true` entry. Unrelated group caches stay warm.

### IdentityChange

Expand Down
5 changes: 5 additions & 0 deletions concepts/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,11 @@ pub trait ProtocolStore: Send + Sync {
async fn set_sender_key_status(&self, group_jid: &str, entries: &[(&str, bool)]) -> Result<()>;
async fn clear_sender_key_devices(&self, group_jid: &str) -> Result<()>;

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

// Clear all sender key device tracking across ALL groups.
// Provided for completeness; not invoked on identity change — WhatsApp Web's
// `WAWebUpdateLocalSignalSession` only calls `markForgetSenderKey` per-group/per-device
Expand Down
20 changes: 19 additions & 1 deletion guides/custom-backends.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,25 @@ impl ProtocolStore for MyCustomStore {
).await?;
Ok(())
}


async fn delete_sender_key_device_rows(&self, device_jids: &[&str]) -> Result<()> {
if device_jids.is_empty() {
return Ok(());
}
// Chunk to keep parameter counts under SQLite's bind variable limit.
for chunk in device_jids.chunks(190) {
let placeholders = std::iter::repeat("?")
.take(chunk.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"DELETE FROM sender_key_devices WHERE device_jid IN ({placeholders})"
);
self.connection.execute(&sql, chunk).await?;
}
Ok(())
}

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

async fn get_lid_mapping(&self, lid: &str) -> Result<Option<LidPnMappingEntry>> {
Expand Down