-
-
Notifications
You must be signed in to change notification settings - Fork 127
fix: address 9 audit findings across correctness, safety, and performance #460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
b1acfcb
b950542
782515c
a65d796
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -132,7 +132,9 @@ impl Client { | |
| /// Increments the retry count for a message and returns the new count. | ||
| /// Returns `None` if max retries have been reached. | ||
| /// | ||
| /// Uses get + insert for portability across cache backends. | ||
| /// Note: get-then-insert has a theoretical TOCTOU window, but messages are | ||
| /// processed sequentially per-chat (mailbox pattern in MessageHandler), so | ||
| /// concurrent increments for the same cache_key are practically impossible. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The TOCTOU window is still reachable here.
Based on learnings: Use 🤖 Prompt for AI Agents |
||
| async fn increment_retry_count(&self, cache_key: &str) -> Option<u8> { | ||
| let current = self.message_retry_counts.get(&cache_key.to_string()).await; | ||
| match current { | ||
|
|
@@ -537,10 +539,10 @@ impl Client { | |
| session_enc_nodes.len() | ||
| ); | ||
|
|
||
| // Skip session processing for group senders (@c.us, @g.us, @broadcast) | ||
| // Groups don't use 1:1 Signal Protocol sessions | ||
| let is_group_sender = sender_encryption_jid.server.contains(".us") | ||
| || sender_encryption_jid.server.contains("broadcast"); | ||
| // Skip session processing for group/broadcast JIDs — they use sender keys, not 1:1 sessions. | ||
| let is_group_sender = sender_encryption_jid.is_group() | ||
| || sender_encryption_jid.is_broadcast_list() | ||
| || sender_encryption_jid.is_status_broadcast(); | ||
|
|
||
| let ( | ||
| session_decrypted_successfully, | ||
|
|
@@ -1034,7 +1036,7 @@ impl Client { | |
| enc_nodes: &[&wacore_binary::node::Node], | ||
| info: &MessageInfo, | ||
| _sender_encryption_jid: &Jid, | ||
| _decrypt_fail_mode: crate::types::events::DecryptFailMode, | ||
| decrypt_fail_mode: crate::types::events::DecryptFailMode, | ||
| ) -> Result<(), DecryptionError> { | ||
| if enc_nodes.is_empty() { | ||
| return Ok(()); | ||
|
|
@@ -1114,6 +1116,7 @@ impl Client { | |
| "No sender key state for group message [msg:{}] from {}: {}. Sending retry receipt.", | ||
| info.id, info.source.sender, msg | ||
| ); | ||
| self.dispatch_undecryptable_event(info, decrypt_fail_mode); | ||
| self.spawn_retry_receipt(info, RetryReason::NoSession); | ||
| } | ||
| Err(e) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,9 +51,10 @@ where | |
| } | ||
|
|
||
| fn remove_key(&mut self, key: &K) -> Option<CacheEntry<V>> { | ||
| let entry = self.map.remove(key)?; | ||
| self.insertion_order.retain(|ik| ik != key); | ||
| Some(entry) | ||
| // Lazy deletion: remove from map but leave stale key in insertion_order. | ||
| // Stale keys are skipped during FIFO eviction (map.remove returns None). | ||
| // run_pending_tasks() periodically compacts insertion_order. | ||
| self.map.remove(key) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This lazy deletion change leaves Useful? React with 👍 / 👎.
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -295,73 +295,94 @@ impl SignalStoreCache { | |
| // === Flush === | ||
|
|
||
| /// Flush all dirty state to the backend in a single batch. | ||
| /// Acquires all 3 mutexes to ensure consistency (matches WhatsApp Web's pattern). | ||
| /// | ||
| /// Sessions are serialized here (not on every store_session call). | ||
| /// Dirty sets are only cleared after ALL writes succeed. | ||
| /// Uses a snapshot-then-release pattern: serialize dirty data under the lock, | ||
| /// release locks, then write to the backend. This avoids blocking all | ||
| /// encrypt/decrypt operations for the duration of I/O. | ||
| /// | ||
| /// Dirty sets are drained before the write phase. If a write fails, the | ||
| /// data remains in the cache and will be re-dirtied on the next modification. | ||
| pub async fn flush(&self, backend: &dyn SignalStore) -> Result<()> { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Snapshot-then-drain can permanently stale the backend. These dirty/deleted sets are cleared before any backend write and Also applies to: 308-384 🤖 Prompt for AI Agents |
||
| let mut sessions = self.sessions.lock().await; | ||
| let mut identities = self.identities.lock().await; | ||
| let mut sender_keys = self.sender_keys.lock().await; | ||
|
|
||
| // Snapshot dirty/deleted sets WITHOUT draining — preserve on failure | ||
| let session_dirty: Vec<_> = sessions.dirty.iter().cloned().collect(); | ||
| let session_deleted: Vec<_> = sessions.deleted.iter().cloned().collect(); | ||
| let identity_dirty: Vec<_> = identities.dirty.iter().cloned().collect(); | ||
| let identity_deleted: Vec<_> = identities.deleted.iter().cloned().collect(); | ||
| let sender_key_dirty: Vec<_> = sender_keys.dirty.iter().cloned().collect(); | ||
|
|
||
| // Persist dirty sessions — serialize only here, not on every store_session | ||
| for address in &session_dirty { | ||
| if let Some(Some(record)) = sessions.cache.get(address.as_ref()) { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("session serialize for {address}: {e}"))?; | ||
| backend.put_session(address, &bytes).await?; | ||
| // Phase 1: snapshot + serialize under lock, then release. | ||
| // Collect dirty keys first, then clear, then serialize from cache. | ||
| let (session_writes, session_deletes) = { | ||
| let mut state = self.sessions.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.drain().collect(); | ||
| let deleted_keys: Vec<_> = state.deleted.drain().collect(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| let mut writes = Vec::with_capacity(dirty_keys.len()); | ||
| for address in &dirty_keys { | ||
| if let Some(Some(record)) = state.cache.get(address.as_ref()) { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("session serialize for {address}: {e}"))?; | ||
| writes.push((address.clone(), bytes)); | ||
| } | ||
| } | ||
| (writes, deleted_keys) | ||
| }; | ||
|
|
||
| let (identity_writes, identity_deletes) = { | ||
| let mut state = self.identities.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.drain().collect(); | ||
| let deleted_keys: Vec<_> = state.deleted.drain().collect(); | ||
| let mut writes = Vec::with_capacity(dirty_keys.len()); | ||
| for address in &dirty_keys { | ||
| if let Some(Some(data)) = state.cache.get(address.as_ref()) { | ||
| let key: [u8; 32] = data.as_ref().try_into().map_err(|_| { | ||
| anyhow::anyhow!( | ||
| "Corrupted identity key for {address}: expected 32 bytes, got {}", | ||
| data.len() | ||
| ) | ||
| })?; | ||
| writes.push((address.clone(), key)); | ||
| } | ||
| } | ||
| (writes, deleted_keys) | ||
| }; | ||
|
|
||
| let sender_key_ops = { | ||
| let mut state = self.sender_keys.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.drain().collect(); | ||
| let mut ops: Vec<(Arc<str>, Option<Vec<u8>>)> = Vec::with_capacity(dirty_keys.len()); | ||
| for name in &dirty_keys { | ||
| match state.cache.get(name.as_ref()) { | ||
| Some(Some(record)) => { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("sender key serialize for {name}: {e}"))?; | ||
| ops.push((name.clone(), Some(bytes))); | ||
| } | ||
| Some(None) => { | ||
| ops.push((name.clone(), None)); | ||
| } | ||
| None => {} | ||
| } | ||
| } | ||
| ops | ||
| }; | ||
|
|
||
| // Phase 2: write to backend without holding any locks. | ||
| for (address, bytes) in &session_writes { | ||
| backend.put_session(address, bytes).await?; | ||
| } | ||
| for address in &session_deleted { | ||
| for address in &session_deletes { | ||
| backend.delete_session(address).await?; | ||
| } | ||
|
|
||
| for address in &identity_dirty { | ||
| if let Some(Some(data)) = identities.cache.get(address.as_ref()) { | ||
| let key: [u8; 32] = data.as_ref().try_into().map_err(|_| { | ||
| anyhow::anyhow!( | ||
| "Corrupted identity key for {address}: expected 32 bytes, got {}", | ||
| data.len() | ||
| ) | ||
| })?; | ||
| backend.put_identity(address, key).await?; | ||
| } | ||
| for (address, key) in &identity_writes { | ||
| backend.put_identity(address, *key).await?; | ||
| } | ||
| for address in &identity_deleted { | ||
| for address in &identity_deletes { | ||
| backend.delete_identity(address).await?; | ||
| } | ||
|
|
||
| for name in &sender_key_dirty { | ||
| match sender_keys.cache.get(name.as_ref()) { | ||
| Some(Some(record)) => { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("sender key serialize for {name}: {e}"))?; | ||
| backend.put_sender_key(name, &bytes).await?; | ||
| } | ||
| Some(None) => { | ||
| // Deleted via delete_sender_key — propagate to backend | ||
| backend.delete_sender_key(name).await?; | ||
| } | ||
| None => {} | ||
| for (name, bytes_opt) in &sender_key_ops { | ||
| match bytes_opt { | ||
| Some(bytes) => backend.put_sender_key(name, bytes).await?, | ||
| None => backend.delete_sender_key(name).await?, | ||
| } | ||
| } | ||
|
|
||
| // All writes succeeded — clear dirty sets (matches WA Web's clearDirty()) | ||
| sessions.dirty.clear(); | ||
| sessions.deleted.clear(); | ||
| identities.dirty.clear(); | ||
| identities.deleted.clear(); | ||
| sender_keys.dirty.clear(); | ||
|
|
||
| Ok(()) | ||
|
Comment on lines
306
to
387
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Note partial-failure semantics for future reference. If, say, sessions flush succeeds but identities flush fails, the sessions dirty set is cleared before the error is returned. On retry, only identities (and sender_keys) will be re-flushed since sessions are already persisted and no longer dirty. This is correct behavior since the session writes did succeed. This differs slightly from the PR description's "clearing dirty sets only after all writes succeed" (which implies a global all-or-nothing), but per-store clearing is the more practical approach given the independent store design. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Event::Disconnectedis now dispatched beforecleanup_connection_state()runs, so synchronous handlers can observe stale connection state (is_connected, transport/noise handles, caches) and make incorrect decisions (for example, skipping reconnect logic because the client still appears connected during the callback). This regression comes from removing the in-loop cleanup call without preserving the prior cleanup-before-dispatch ordering for unexpected disconnects.Useful? React with 👍 / 👎.