From de502c25e777d9c7d06d19131d88a04b3b1649c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:44:46 -0300 Subject: [PATCH 01/10] feat(core): expose signal record components and dirty events --- clippy.toml | 2 +- src/handlers/ib.rs | 42 +- wacore/libsignal/src/core/curve.rs | 9 +- wacore/libsignal/src/protocol/mod.rs | 8 + wacore/libsignal/src/protocol/protocol.rs | 2 +- .../src/protocol/record_components.rs | 945 ++++++++++++++++++ wacore/libsignal/src/protocol/sender_keys.rs | 64 ++ .../libsignal/src/protocol/state/session.rs | 56 ++ wacore/src/types/events.rs | 34 + 9 files changed, 1157 insertions(+), 5 deletions(-) create mode 100644 wacore/libsignal/src/protocol/record_components.rs diff --git a/clippy.toml b/clippy.toml index bb4ea8f1a..1b1a39bdb 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,6 +1,6 @@ disallowed-methods = [ { path = "chrono::Utc::now", reason = "use wacore::time::now_utc() to respect the pluggable TimeProvider (WASM + deterministic tests)" }, - { path = "chrono::Local::now", reason = "use wacore::time::now_utc() (Local depends on SystemTime which panics on WASM)" }, + { path = "chrono::Local::now", reason = "use wacore::time::now_utc() to respect the pluggable TimeProvider and avoid direct system-clock access", allow-invalid = true }, { path = "std::time::SystemTime::now", reason = "use wacore::time::now_millis() / now_utc()" }, { path = "std::time::Instant::now", reason = "use wacore::time::Instant::now()" }, # buffa's Message codec methods are generic over the buffer type, so every diff --git a/src/handlers/ib.rs b/src/handlers/ib.rs index 6350d55fe..7bd366fcd 100644 --- a/src/handlers/ib.rs +++ b/src/handlers/ib.rs @@ -1,6 +1,6 @@ use super::traits::StanzaHandler; use crate::client::Client; -use crate::types::events::{Event, OfflineSyncPreview}; +use crate::types::events::{DirtyState, Event, OfflineSyncPreview}; use async_trait::async_trait; use futures::FutureExt; use log::{debug, info, warn}; @@ -68,6 +68,13 @@ async fn handle_ib_impl(client: Arc, node: &wacore_binary::NodeRef<'_>) ); let needs_resync = bit.dirty_type == DirtyType::SyncdAppState; + client.core.event_bus.dispatch(Event::DirtyState( + DirtyState::builder() + .dirty_type(bit.dirty_type.clone()) + .maybe_timestamp(bit.timestamp) + .build(), + )); + debug!( "Received dirty state notification for type: '{dirty_type_str}'. Sending clean IQ." ); @@ -216,3 +223,36 @@ async fn handle_ib_impl(client: Arc, node: &wacore_binary::NodeRef<'_>) } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{TestEventCollector, create_test_client}; + use wacore_binary::builder::NodeBuilder; + + #[tokio::test] + async fn valid_dirty_marker_dispatches_typed_event() { + let client = create_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.register_handler(collector.clone()); + let node = NodeBuilder::new("ib") + .children([NodeBuilder::new("dirty") + .attr("type", "account_sync") + .attr("timestamp", "1725000000") + .build()]) + .build(); + + handle_ib_impl(client, &node.as_node_ref()).await; + + assert!(collector.events().iter().any(|event| { + matches!( + &**event, + Event::DirtyState(DirtyState { + dirty_type: DirtyType::AccountSync, + timestamp: Some(1_725_000_000), + .. + }) + ) + })); + } +} diff --git a/wacore/libsignal/src/core/curve.rs b/wacore/libsignal/src/core/curve.rs index 505dd7d5c..4acbee9d3 100644 --- a/wacore/libsignal/src/core/curve.rs +++ b/wacore/libsignal/src/core/curve.rs @@ -67,6 +67,11 @@ pub struct PublicKey { } impl PublicKey { + /// Length of a raw Curve25519 public key, without its type prefix. + pub const RAW_KEY_LEN: usize = curve25519::PUBLIC_KEY_LENGTH; + /// Length of the canonical serialized form, including its type prefix. + pub const SERIALIZED_KEY_LEN: usize = Self::RAW_KEY_LEN + 1; + fn new(key: PublicKeyData) -> Self { Self { key } } @@ -107,8 +112,8 @@ impl PublicKey { } /// Serialize the public key to a fixed-size array (1 type byte + 32 key bytes). - pub fn serialize(&self) -> [u8; 33] { - let mut result = [0u8; 33]; + pub fn serialize(&self) -> [u8; Self::SERIALIZED_KEY_LEN] { + let mut result = [0u8; Self::SERIALIZED_KEY_LEN]; result[0] = self.key_type().value(); match &self.key { PublicKeyData::DjbPublicKey(v) => result[1..].copy_from_slice(v), diff --git a/wacore/libsignal/src/protocol/mod.rs b/wacore/libsignal/src/protocol/mod.rs index dc05c1b98..43be4f824 100644 --- a/wacore/libsignal/src/protocol/mod.rs +++ b/wacore/libsignal/src/protocol/mod.rs @@ -26,6 +26,7 @@ mod local_field; #[allow(clippy::module_inception)] mod protocol; mod ratchet; +mod record_components; mod sender_keys; pub mod session; mod session_cipher; @@ -56,6 +57,13 @@ pub use ratchet::{ RootKey, UsePQRatchet, derive_keys, initialize_alice_session_record, initialize_bob_session, initialize_bob_session_record, }; +pub use record_components::{ + PendingKeyExchangeComponents, PendingPreKeyComponents, SenderChainKeyComponents, + SenderKeyRecordComponents, SenderKeyStateComponents, SenderMessageKeyComponents, + SenderSigningKeyComponents, SessionChainComponents, SessionChainKeyComponents, + SessionComponents, SessionMessageKeyComponents, SessionMessageKeyMaterial, + SessionRecordComponents, +}; pub use sender_keys::{SenderKeyRecord, SenderKeyState}; pub use session::{process_prekey, process_prekey_bundle}; pub use session_cipher::{ diff --git a/wacore/libsignal/src/protocol/protocol.rs b/wacore/libsignal/src/protocol/protocol.rs index 5f4ea24dd..c91d48424 100644 --- a/wacore/libsignal/src/protocol/protocol.rs +++ b/wacore/libsignal/src/protocol/protocol.rs @@ -106,7 +106,7 @@ fn decode_message_version(version_byte: u8) -> u8 { } // Signal's original implementation uses version 4, but WhatsApp Web, -// Baileys (libsignal-node), and whatsmeow all use version 3. +// Interoperable Signal implementations use version 3. pub const CIPHERTEXT_MESSAGE_CURRENT_VERSION: u8 = 3; pub const SENDERKEY_MESSAGE_CURRENT_VERSION: u8 = 3; diff --git a/wacore/libsignal/src/protocol/record_components.rs b/wacore/libsignal/src/protocol/record_components.rs new file mode 100644 index 000000000..0ccb8d39e --- /dev/null +++ b/wacore/libsignal/src/protocol/record_components.rs @@ -0,0 +1,945 @@ +//! Owned, validated projections of persisted Signal records. +//! +//! These types keep codec-generated structures private while providing a +//! stable representation for record handoff. Conversion validates fixed-width +//! key material and emits public keys in their canonical serialized form. The +//! explicit field mappings are intentional: a generated schema change must +//! fail to compile here instead of silently dropping protocol state. + +use std::fmt; + +use buffa::MessageField; +use bytes::Bytes; + +use crate::core::curve::PublicKey; +use crate::protocol::error::{Result, SignalProtocolError}; +use crate::protocol::ratchet::MessageKeyGenerator; +use crate::protocol::stores::{ + SenderKeyStateStructure, SessionStructure, sender_key_state_structure, session_structure, +}; + +const PRIVATE_KEY_BYTES: usize = 32; +const SYMMETRIC_KEY_BYTES: usize = 32; +const MESSAGE_IV_BYTES: usize = 16; + +/// Complete protocol state carried by a session record. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SessionRecordComponents { + pub current_session: Option, + pub previous_sessions: Vec, +} + +/// One current or archived session state. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct SessionComponents { + pub session_version: Option, + pub local_identity_public: Option>, + pub remote_identity_public: Option>, + pub root_key: Option>, + pub previous_counter: Option, + pub sender_chain: Option, + pub receiver_chains: Vec, + pub pending_key_exchange: Option, + pub pending_pre_key: Option, + pub remote_registration_id: Option, + pub local_registration_id: Option, + pub needs_refresh: Option, + pub alice_base_key: Option>, +} + +/// A sender or receiver ratchet chain and its skipped message keys. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct SessionChainComponents { + pub sender_ratchet_key: Option>, + pub sender_ratchet_key_private: Option>, + pub chain_key: Option, + pub message_keys: Vec, +} + +/// Position and secret material for a session chain. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct SessionChainKeyComponents { + pub index: Option, + pub key: Option>, +} + +/// A skipped message key identified by its chain index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionMessageKeyComponents { + pub index: u32, + pub material: SessionMessageKeyMaterial, +} + +/// Secret material used by a skipped session message key. +/// +/// `Seed` is accepted as a compact import form and is expanded with the +/// protocol's canonical derivation. Exported records always use `Derived`. +#[derive(Clone, PartialEq, Eq)] +pub enum SessionMessageKeyMaterial { + Seed(Vec), + Derived { + cipher_key: Vec, + mac_key: Vec, + iv: Vec, + }, +} + +/// Pending key-exchange state. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct PendingKeyExchangeComponents { + pub sequence: Option, + pub local_base_key: Option>, + pub local_base_key_private: Option>, + pub local_ratchet_key: Option>, + pub local_ratchet_key_private: Option>, + pub local_identity_key: Option>, + pub local_identity_key_private: Option>, +} + +/// Pending pre-key state. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct PendingPreKeyComponents { + pub pre_key_id: Option, + pub signed_pre_key_id: Option, + pub base_key: Option>, + pub kyber_pre_key_id: Option, + pub kyber_ciphertext: Option>, +} + +/// Complete protocol state carried by a sender-key record. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SenderKeyRecordComponents { + pub states: Vec, +} + +/// One sender-key state, including skipped message keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SenderKeyStateComponents { + pub key_id: u32, + pub chain_key: SenderChainKeyComponents, + pub signing_key: SenderSigningKeyComponents, + pub message_keys: Vec, +} + +/// Position and secret material for a sender chain. +#[derive(Clone, PartialEq, Eq)] +pub struct SenderChainKeyComponents { + pub iteration: u32, + pub seed: Vec, +} + +/// Public and optional private signing-key material. +#[derive(Clone, PartialEq, Eq)] +pub struct SenderSigningKeyComponents { + pub public: Vec, + pub private: Option>, +} + +/// A skipped sender message key identified by its iteration. +#[derive(Clone, PartialEq, Eq)] +pub struct SenderMessageKeyComponents { + pub iteration: u32, + pub seed: Vec, +} + +struct Redacted; + +impl fmt::Debug for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +macro_rules! impl_redacted_debug { + ($type:ident { visible: [$($visible:ident),* $(,)?], secret: [$($secret:ident),* $(,)?] }) => { + impl fmt::Debug for $type { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct(stringify!($type)); + $(debug.field(stringify!($visible), &self.$visible);)* + $(debug.field(stringify!($secret), &Redacted);)* + debug.finish() + } + } + }; +} + +impl_redacted_debug!(SessionComponents { + visible: [ + session_version, + previous_counter, + sender_chain, + receiver_chains, + pending_key_exchange, + pending_pre_key, + remote_registration_id, + local_registration_id, + needs_refresh, + ], + secret: [ + local_identity_public, + remote_identity_public, + root_key, + alice_base_key, + ] +}); +impl_redacted_debug!(SessionChainComponents { + visible: [chain_key, message_keys], + secret: [sender_ratchet_key, sender_ratchet_key_private] +}); +impl_redacted_debug!(SessionChainKeyComponents { + visible: [index], + secret: [key] +}); +impl_redacted_debug!(PendingKeyExchangeComponents { + visible: [sequence], + secret: [ + local_base_key, + local_base_key_private, + local_ratchet_key, + local_ratchet_key_private, + local_identity_key, + local_identity_key_private, + ] +}); +impl_redacted_debug!(PendingPreKeyComponents { + visible: [pre_key_id, signed_pre_key_id, kyber_pre_key_id], + secret: [base_key, kyber_ciphertext] +}); +impl_redacted_debug!(SenderChainKeyComponents { + visible: [iteration], + secret: [seed] +}); +impl_redacted_debug!(SenderSigningKeyComponents { + visible: [], + secret: [public, private] +}); +impl_redacted_debug!(SenderMessageKeyComponents { + visible: [iteration], + secret: [seed] +}); + +impl fmt::Debug for SessionMessageKeyMaterial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Seed(_) => f.debug_tuple("Seed").field(&Redacted).finish(), + Self::Derived { .. } => f + .debug_struct("Derived") + .field("cipher_key", &Redacted) + .field("mac_key", &Redacted) + .field("iv", &Redacted) + .finish(), + } + } +} + +fn invalid(field: &'static str, expectation: &'static str) -> SignalProtocolError { + SignalProtocolError::InvalidArgument(format!("{field} must be {expectation}")) +} + +fn normalize_public_key(mut value: Vec, field: &'static str) -> Result> { + let key = match value.len() { + PublicKey::RAW_KEY_LEN => PublicKey::from_djb_public_key_bytes(&value), + PublicKey::SERIALIZED_KEY_LEN => PublicKey::deserialize(&value), + _ => { + return Err(invalid(field, "a raw or canonically serialized public key")); + } + } + .map_err(|_| invalid(field, "a valid public key"))?; + + value.clear(); + value.extend_from_slice(&key.serialize()); + Ok(value) +} + +fn optional_public_key(value: Option>, field: &'static str) -> Result>> { + value + .map(|value| normalize_public_key(value, field)) + .transpose() +} + +fn exact_bytes(value: Vec, length: usize, field: &'static str) -> Result> { + if value.len() == length { + Ok(value) + } else { + Err(SignalProtocolError::InvalidArgument(format!( + "{field} must be {length} bytes" + ))) + } +} + +fn optional_exact_bytes( + value: Option>, + length: usize, + field: &'static str, +) -> Result>> { + value + .map(|value| exact_bytes(value, length, field)) + .transpose() +} + +impl SessionMessageKeyComponents { + fn into_structure(self) -> Result { + match self.material { + SessionMessageKeyMaterial::Seed(seed) => { + let seed: [u8; SYMMETRIC_KEY_BYTES] = seed + .try_into() + .map_err(|_| invalid("session message-key seed", "32 bytes"))?; + Ok(MessageKeyGenerator::new_from_seed(&seed, self.index).into_pb()) + } + SessionMessageKeyMaterial::Derived { + cipher_key, + mac_key, + iv, + } => Ok(session_structure::chain::MessageKey { + index: Some(self.index), + cipher_key: Some(Bytes::from(exact_bytes( + cipher_key, + SYMMETRIC_KEY_BYTES, + "session message cipher key", + )?)), + mac_key: Some(Bytes::from(exact_bytes( + mac_key, + SYMMETRIC_KEY_BYTES, + "session message MAC key", + )?)), + iv: Some(Bytes::from(exact_bytes( + iv, + MESSAGE_IV_BYTES, + "session message IV", + )?)), + }), + } + } + + fn from_structure(value: session_structure::chain::MessageKey) -> Result { + Ok(Self { + index: value + .index + .ok_or_else(|| invalid("session message-key index", "present"))?, + material: SessionMessageKeyMaterial::Derived { + cipher_key: exact_bytes( + value + .cipher_key + .ok_or_else(|| invalid("session message cipher key", "present"))? + .to_vec(), + SYMMETRIC_KEY_BYTES, + "session message cipher key", + )?, + mac_key: exact_bytes( + value + .mac_key + .ok_or_else(|| invalid("session message MAC key", "present"))? + .to_vec(), + SYMMETRIC_KEY_BYTES, + "session message MAC key", + )?, + iv: exact_bytes( + value + .iv + .ok_or_else(|| invalid("session message IV", "present"))? + .to_vec(), + MESSAGE_IV_BYTES, + "session message IV", + )?, + }, + }) + } +} + +impl SessionChainKeyComponents { + fn into_structure(self) -> Result { + Ok(session_structure::chain::ChainKey { + index: self.index, + key: optional_exact_bytes(self.key, SYMMETRIC_KEY_BYTES, "session chain key")? + .map(Bytes::from), + }) + } + + fn from_structure(value: session_structure::chain::ChainKey) -> Result { + Ok(Self { + index: value.index, + key: optional_exact_bytes( + value.key.map(|value| value.to_vec()), + SYMMETRIC_KEY_BYTES, + "session chain key", + )?, + }) + } +} + +impl SessionChainComponents { + fn into_structure(self) -> Result { + Ok(session_structure::Chain { + sender_ratchet_key: optional_public_key( + self.sender_ratchet_key, + "session ratchet public key", + )?, + sender_ratchet_key_private: optional_exact_bytes( + self.sender_ratchet_key_private, + PRIVATE_KEY_BYTES, + "session ratchet private key", + )?, + chain_key: self + .chain_key + .map(SessionChainKeyComponents::into_structure) + .transpose()? + .into(), + message_keys: self + .message_keys + .into_iter() + .map(SessionMessageKeyComponents::into_structure) + .collect::>()?, + }) + } + + fn from_structure(mut value: session_structure::Chain) -> Result { + Ok(Self { + sender_ratchet_key: optional_public_key( + value.sender_ratchet_key, + "session ratchet public key", + )?, + sender_ratchet_key_private: optional_exact_bytes( + value.sender_ratchet_key_private, + PRIVATE_KEY_BYTES, + "session ratchet private key", + )?, + chain_key: value + .chain_key + .take() + .map(SessionChainKeyComponents::from_structure) + .transpose()?, + message_keys: value + .message_keys + .into_iter() + .map(SessionMessageKeyComponents::from_structure) + .collect::>()?, + }) + } +} + +impl PendingKeyExchangeComponents { + fn into_structure(self) -> Result { + Ok(session_structure::PendingKeyExchange { + sequence: self.sequence, + local_base_key: optional_public_key(self.local_base_key, "local base public key")?, + local_base_key_private: optional_exact_bytes( + self.local_base_key_private, + PRIVATE_KEY_BYTES, + "local base private key", + )?, + local_ratchet_key: optional_public_key( + self.local_ratchet_key, + "local ratchet public key", + )?, + local_ratchet_key_private: optional_exact_bytes( + self.local_ratchet_key_private, + PRIVATE_KEY_BYTES, + "local ratchet private key", + )?, + local_identity_key: optional_public_key( + self.local_identity_key, + "local identity public key", + )?, + local_identity_key_private: optional_exact_bytes( + self.local_identity_key_private, + PRIVATE_KEY_BYTES, + "local identity private key", + )?, + }) + } + + fn from_structure(value: session_structure::PendingKeyExchange) -> Result { + Ok(Self { + sequence: value.sequence, + local_base_key: optional_public_key(value.local_base_key, "local base public key")?, + local_base_key_private: optional_exact_bytes( + value.local_base_key_private, + PRIVATE_KEY_BYTES, + "local base private key", + )?, + local_ratchet_key: optional_public_key( + value.local_ratchet_key, + "local ratchet public key", + )?, + local_ratchet_key_private: optional_exact_bytes( + value.local_ratchet_key_private, + PRIVATE_KEY_BYTES, + "local ratchet private key", + )?, + local_identity_key: optional_public_key( + value.local_identity_key, + "local identity public key", + )?, + local_identity_key_private: optional_exact_bytes( + value.local_identity_key_private, + PRIVATE_KEY_BYTES, + "local identity private key", + )?, + }) + } +} + +impl PendingPreKeyComponents { + fn into_structure(self) -> Result { + Ok(session_structure::PendingPreKey { + pre_key_id: self.pre_key_id, + signed_pre_key_id: self.signed_pre_key_id, + base_key: optional_public_key(self.base_key, "pending pre-key base key")?, + kyber_pre_key_id: self.kyber_pre_key_id, + kyber_ciphertext: self.kyber_ciphertext, + }) + } + + fn from_structure(value: session_structure::PendingPreKey) -> Result { + Ok(Self { + pre_key_id: value.pre_key_id, + signed_pre_key_id: value.signed_pre_key_id, + base_key: optional_public_key(value.base_key, "pending pre-key base key")?, + kyber_pre_key_id: value.kyber_pre_key_id, + kyber_ciphertext: value.kyber_ciphertext, + }) + } +} + +pub(crate) fn session_structure_from_components( + value: SessionComponents, +) -> Result { + Ok(SessionStructure { + session_version: value.session_version, + local_identity_public: optional_public_key( + value.local_identity_public, + "local identity public key", + )?, + remote_identity_public: optional_public_key( + value.remote_identity_public, + "remote identity public key", + )?, + root_key: optional_exact_bytes(value.root_key, SYMMETRIC_KEY_BYTES, "session root key")?, + previous_counter: value.previous_counter, + sender_chain: value + .sender_chain + .map(SessionChainComponents::into_structure) + .transpose()? + .into(), + receiver_chains: value + .receiver_chains + .into_iter() + .map(SessionChainComponents::into_structure) + .collect::>()?, + pending_key_exchange: value + .pending_key_exchange + .map(PendingKeyExchangeComponents::into_structure) + .transpose()? + .into(), + pending_pre_key: value + .pending_pre_key + .map(PendingPreKeyComponents::into_structure) + .transpose()? + .into(), + remote_registration_id: value.remote_registration_id, + local_registration_id: value.local_registration_id, + needs_refresh: value.needs_refresh, + alice_base_key: optional_public_key(value.alice_base_key, "session base public key")?, + }) +} + +pub(crate) fn session_components_from_structure( + mut value: SessionStructure, +) -> Result { + Ok(SessionComponents { + session_version: value.session_version, + local_identity_public: optional_public_key( + value.local_identity_public, + "local identity public key", + )?, + remote_identity_public: optional_public_key( + value.remote_identity_public, + "remote identity public key", + )?, + root_key: optional_exact_bytes(value.root_key, SYMMETRIC_KEY_BYTES, "session root key")?, + previous_counter: value.previous_counter, + sender_chain: value + .sender_chain + .take() + .map(SessionChainComponents::from_structure) + .transpose()?, + receiver_chains: value + .receiver_chains + .into_iter() + .map(SessionChainComponents::from_structure) + .collect::>()?, + pending_key_exchange: value + .pending_key_exchange + .take() + .map(PendingKeyExchangeComponents::from_structure) + .transpose()?, + pending_pre_key: value + .pending_pre_key + .take() + .map(PendingPreKeyComponents::from_structure) + .transpose()?, + remote_registration_id: value.remote_registration_id, + local_registration_id: value.local_registration_id, + needs_refresh: value.needs_refresh, + alice_base_key: optional_public_key(value.alice_base_key, "session base public key")?, + }) +} + +pub(crate) fn sender_state_structure_from_components( + value: SenderKeyStateComponents, +) -> Result { + let chain_seed = exact_bytes( + value.chain_key.seed, + SYMMETRIC_KEY_BYTES, + "sender chain seed", + )?; + let signing_public = + normalize_public_key(value.signing_key.public, "sender signing public key")?; + let signing_private = optional_exact_bytes( + value.signing_key.private, + PRIVATE_KEY_BYTES, + "sender signing private key", + )?; + Ok(SenderKeyStateStructure { + sender_key_id: Some(value.key_id), + sender_chain_key: MessageField::some(sender_key_state_structure::SenderChainKey { + iteration: Some(value.chain_key.iteration), + seed: Some(Bytes::from(chain_seed)), + }), + sender_signing_key: MessageField::some(sender_key_state_structure::SenderSigningKey { + public: Some(Bytes::from(signing_public)), + private: signing_private.map(Bytes::from), + }), + sender_message_keys: value + .message_keys + .into_iter() + .map(|key| { + Ok(sender_key_state_structure::SenderMessageKey { + iteration: Some(key.iteration), + seed: Some(Bytes::from(exact_bytes( + key.seed, + SYMMETRIC_KEY_BYTES, + "sender message-key seed", + )?)), + }) + }) + .collect::>()?, + }) +} + +pub(crate) fn sender_state_components_from_structure( + mut value: SenderKeyStateStructure, +) -> Result { + let chain = value + .sender_chain_key + .take() + .ok_or_else(|| invalid("sender chain key", "present"))?; + let signing = value + .sender_signing_key + .take() + .ok_or_else(|| invalid("sender signing key", "present"))?; + Ok(SenderKeyStateComponents { + key_id: value + .sender_key_id + .ok_or_else(|| invalid("sender key id", "present"))?, + chain_key: SenderChainKeyComponents { + iteration: chain + .iteration + .ok_or_else(|| invalid("sender chain iteration", "present"))?, + seed: exact_bytes( + chain + .seed + .ok_or_else(|| invalid("sender chain seed", "present"))? + .to_vec(), + SYMMETRIC_KEY_BYTES, + "sender chain seed", + )?, + }, + signing_key: SenderSigningKeyComponents { + public: normalize_public_key( + signing + .public + .ok_or_else(|| invalid("sender signing public key", "present"))? + .to_vec(), + "sender signing public key", + )?, + private: optional_exact_bytes( + signing.private.map(|value| value.to_vec()), + PRIVATE_KEY_BYTES, + "sender signing private key", + )?, + }, + message_keys: value + .sender_message_keys + .into_iter() + .map(|key| { + Ok(SenderMessageKeyComponents { + iteration: key + .iteration + .ok_or_else(|| invalid("sender message-key iteration", "present"))?, + seed: exact_bytes( + key.seed + .ok_or_else(|| invalid("sender message-key seed", "present"))? + .to_vec(), + SYMMETRIC_KEY_BYTES, + "sender message-key seed", + )?, + }) + }) + .collect::>()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{SenderKeyRecord, SessionRecord}; + + fn public_key(seed: u8) -> Vec { + PublicKey::from_djb_public_key_bytes(&[seed; PublicKey::RAW_KEY_LEN]) + .expect("valid test key") + .serialize() + .to_vec() + } + + fn session_record() -> SessionRecordComponents { + let current_session = SessionComponents { + session_version: Some(3), + local_identity_public: Some(public_key(1)), + remote_identity_public: Some(public_key(2)), + root_key: Some(vec![3; 32]), + previous_counter: Some(0), + sender_chain: Some(SessionChainComponents { + sender_ratchet_key: Some(public_key(4)), + sender_ratchet_key_private: Some(vec![5; 32]), + chain_key: Some(SessionChainKeyComponents { + index: Some(0), + key: Some(vec![6; 32]), + }), + message_keys: vec![SessionMessageKeyComponents { + index: 1, + material: SessionMessageKeyMaterial::Derived { + cipher_key: vec![16; 32], + mac_key: vec![17; 32], + iv: vec![18; 16], + }, + }], + }), + receiver_chains: vec![SessionChainComponents { + sender_ratchet_key: Some(public_key(7)), + sender_ratchet_key_private: None, + chain_key: Some(SessionChainKeyComponents { + index: Some(2), + key: Some(vec![8; 32]), + }), + message_keys: vec![SessionMessageKeyComponents { + index: 3, + material: SessionMessageKeyMaterial::Derived { + cipher_key: vec![19; 32], + mac_key: vec![20; 32], + iv: vec![21; 16], + }, + }], + }], + pending_key_exchange: Some(PendingKeyExchangeComponents { + sequence: Some(22), + local_base_key: Some(public_key(23)), + local_base_key_private: Some(vec![24; 32]), + local_ratchet_key: Some(public_key(25)), + local_ratchet_key_private: Some(vec![26; 32]), + local_identity_key: Some(public_key(27)), + local_identity_key_private: Some(vec![28; 32]), + }), + pending_pre_key: Some(PendingPreKeyComponents { + pre_key_id: Some(29), + signed_pre_key_id: Some(30), + base_key: Some(public_key(31)), + kyber_pre_key_id: Some(32), + kyber_ciphertext: Some(vec![33; 48]), + }), + remote_registration_id: Some(9), + local_registration_id: Some(10), + needs_refresh: Some(false), + alice_base_key: Some(public_key(11)), + }; + SessionRecordComponents { + current_session: Some(current_session.clone()), + previous_sessions: vec![current_session], + } + } + + fn sender_key_record() -> SenderKeyRecordComponents { + SenderKeyRecordComponents { + states: vec![ + SenderKeyStateComponents { + key_id: 17, + chain_key: SenderChainKeyComponents { + iteration: 0, + seed: vec![12; 32], + }, + signing_key: SenderSigningKeyComponents { + public: public_key(13), + private: Some(vec![14; 32]), + }, + message_keys: vec![SenderMessageKeyComponents { + iteration: 3, + seed: vec![15; 32], + }], + }, + SenderKeyStateComponents { + key_id: 18, + chain_key: SenderChainKeyComponents { + iteration: 4, + seed: vec![16; 32], + }, + signing_key: SenderSigningKeyComponents { + public: public_key(17), + private: None, + }, + message_keys: Vec::new(), + }, + ], + } + } + + #[test] + fn raw_public_keys_are_normalized_once() { + let normalized = normalize_public_key(vec![7; 32], "test key").expect("valid key"); + assert_eq!(normalized.len(), PublicKey::SERIALIZED_KEY_LEN); + assert_eq!(normalized[0], crate::core::curve::KeyType::Djb.value()); + assert_eq!(&normalized[1..], &[7; 32]); + } + + #[test] + fn invalid_serialized_public_key_type_is_rejected() { + let mut key = public_key(7); + key[0] = u8::MAX; + + assert!(normalize_public_key(key, "test key").is_err()); + } + + #[test] + fn debug_output_redacts_key_material() { + let chain = SessionChainKeyComponents { + index: Some(7), + key: Some(vec![42; 32]), + }; + let material = SessionMessageKeyMaterial::Derived { + cipher_key: vec![1; 32], + mac_key: vec![2; 32], + iv: vec![3; 16], + }; + + assert_eq!( + format!("{chain:?}"), + "SessionChainKeyComponents { index: Some(7), key: }" + ); + assert_eq!( + format!("{material:?}"), + "Derived { cipher_key: , mac_key: , iv: }" + ); + } + + #[test] + fn message_key_seed_uses_the_existing_derivation() { + let seed = [9; 32]; + let expected = MessageKeyGenerator::new_from_seed(&seed, 17).into_pb(); + let actual = SessionMessageKeyComponents { + index: 17, + material: SessionMessageKeyMaterial::Seed(seed.to_vec()), + } + .into_structure() + .expect("valid seed"); + + assert_eq!(actual, expected); + } + + #[test] + fn invalid_seed_length_is_rejected() { + let error = SessionMessageKeyComponents { + index: 1, + material: SessionMessageKeyMaterial::Seed(vec![0; 31]), + } + .into_structure() + .expect_err("short seed must fail"); + + assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); + } + + #[test] + fn session_components_use_the_canonical_record_codec() { + let expected = session_record(); + let bytes = SessionRecord::from_components(expected.clone()) + .expect("valid components") + .serialize() + .expect("serialize record"); + let actual = SessionRecord::deserialize(&bytes) + .expect("deserialize record") + .into_components() + .expect("project record"); + + assert_eq!(actual, expected); + } + + #[test] + fn session_components_bound_archived_state_count() { + let mut components = session_record(); + let archived = components + .current_session + .clone() + .expect("test session is present"); + components.previous_sessions = + vec![archived; crate::protocol::consts::ARCHIVED_STATES_MAX_LENGTH + 1]; + + let actual = SessionRecord::from_components(components) + .expect("valid components") + .into_components() + .expect("project record"); + + assert_eq!( + actual.previous_sessions.len(), + crate::protocol::consts::ARCHIVED_STATES_MAX_LENGTH + ); + } + + #[test] + fn session_handoff_burns_the_reserved_sender_range() { + let mut record = SessionRecord::from_components(session_record()).expect("valid record"); + record.reserve_sender_chain_counters(0); + let ceiling = record.reserved_sender_chain_index(); + let components = record.into_components().expect("safe handoff"); + let index = components + .current_session + .and_then(|session| session.sender_chain) + .and_then(|chain| chain.chain_key) + .and_then(|chain| chain.index); + + assert_eq!(index, Some(ceiling)); + } + + #[test] + fn sender_key_components_use_the_canonical_record_codec() { + let expected = sender_key_record(); + let bytes = SenderKeyRecord::from_components(expected.clone()) + .expect("valid components") + .serialize() + .expect("serialize record"); + let actual = SenderKeyRecord::deserialize(&bytes) + .expect("deserialize record") + .into_components() + .expect("project record"); + + assert_eq!(actual, expected); + } + + #[test] + fn sender_key_handoff_burns_the_reserved_iteration_range() { + let mut record = + SenderKeyRecord::from_components(sender_key_record()).expect("valid record"); + record.reserve_iterations(0); + let ceiling = record.reserved_iteration(); + let components = record.into_components().expect("safe handoff"); + + assert_eq!(components.states[0].chain_key.iteration, ceiling); + } +} diff --git a/wacore/libsignal/src/protocol/sender_keys.rs b/wacore/libsignal/src/protocol/sender_keys.rs index dd736dbb0..ee2a7f65b 100644 --- a/wacore/libsignal/src/protocol/sender_keys.rs +++ b/wacore/libsignal/src/protocol/sender_keys.rs @@ -11,6 +11,10 @@ use hmac::{HmacReset, KeyInit, Mac}; use sha2::Sha256; use crate::protocol::crypto::hmac_sha256; +use crate::protocol::record_components::{ + SenderKeyRecordComponents, sender_state_components_from_structure, + sender_state_structure_from_components, +}; use crate::protocol::stores::{ SenderKeyRecordStructure, SenderKeyStateStructure, sender_key_state_structure, }; @@ -438,6 +442,27 @@ impl SenderKeyState { state } + fn into_protobuf(mut self) -> SenderKeyStateStructure { + debug_assert!( + self.state.sender_message_keys.is_empty() + && self.state.sender_chain_key.as_option().is_none(), + "backlog and chain key must have a single in-memory owner" + ); + let message_keys = std::sync::Arc::try_unwrap(self.message_keys) + .unwrap_or_else(|shared| shared.as_ref().clone()); + self.state.sender_message_keys = message_keys + .iter() + .map(StoredMessageKey::as_protobuf) + .collect(); + self.state.sender_chain_key = self + .sender_chain + .as_ref() + .map_or_else(MessageField::none, |chain| { + MessageField::some(chain.as_protobuf()) + }); + self.state + } + pub fn add_sender_message_key(&mut self, sender_message_key: &SenderMessageKey) { let keys = std::sync::Arc::make_mut(&mut self.message_keys); keys.push(StoredMessageKey { @@ -508,6 +533,45 @@ impl SenderKeyRecord { } } + /// Builds a record from validated protocol components. + /// + /// Components do not carry process-local durability metadata, so the + /// imported current chain starts a fresh reservation lifecycle. + pub fn from_components(value: SenderKeyRecordComponents) -> Result { + let states = value + .states + .into_iter() + .map(sender_state_structure_from_components) + .map(|state| state.map(SenderKeyState::from_protobuf)) + .collect::, _>>()?; + + Ok(Self { + states, + wire_gated: false, + reserved_iteration: 0, + }) + } + + /// Consumes the record and projects its protocol components. + /// + /// Any durably reserved sender range is advanced to its exclusive ceiling + /// before export so rebuilding the record cannot derive a possibly spent + /// message key again. + pub fn into_components(mut self) -> Result { + if self.reserved_iteration > 0 + && let Some(state) = self.states.front_mut() + { + state.fast_forward_sender_chain(self.reserved_iteration)?; + } + let states = self + .states + .into_iter() + .map(SenderKeyState::into_protobuf) + .map(sender_state_components_from_structure) + .collect::, _>>()?; + Ok(SenderKeyRecordComponents { states }) + } + pub fn is_empty(&self) -> bool { self.states.is_empty() } diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index e91cf30ff..bb85e0d59 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -13,6 +13,9 @@ use subtle::ConstantTimeEq; use crate::core::curve::KeyType; use crate::protocol::ratchet::keys::MessageKeyGenerator; use crate::protocol::ratchet::{ChainKey, RootKey}; +use crate::protocol::record_components::{ + SessionRecordComponents, session_components_from_structure, session_structure_from_components, +}; use crate::protocol::state::{PreKeyId, SignedPreKeyId}; use crate::protocol::stores::SessionStructure; use crate::protocol::stores::session_structure::{self}; @@ -712,6 +715,59 @@ impl SessionRecord { } } + /// Builds a record from validated protocol components. + /// + /// Components do not carry process-local durability metadata. The imported + /// chain therefore starts a fresh reservation lifecycle, while archived + /// sessions are bounded to the same limit used by record deserialization. + pub fn from_components(value: SessionRecordComponents) -> Result { + let current_session = value + .current_session + .map(session_structure_from_components) + .transpose()? + .map(SessionState::from_session_structure); + let previous_sessions = value + .previous_sessions + .into_iter() + .take(consts::ARCHIVED_STATES_MAX_LENGTH) + .map(session_structure_from_components) + .collect::, _>>()?; + + Ok(Self { + current_session, + previous_sessions: Arc::new(previous_sessions), + reserved_sender_chain_index: 0, + pending_reservation: false, + }) + } + + /// Consumes the record and projects its protocol components. + /// + /// Any durably reserved sender range is advanced to its exclusive ceiling + /// before export so rebuilding the record cannot derive a possibly spent + /// message key again. + pub fn into_components(mut self) -> Result { + if self.reserved_sender_chain_index > 0 + && let Some(state) = self.current_session.as_mut() + { + state.fast_forward_sender_chain(self.reserved_sender_chain_index)?; + } + let current_session = self + .current_session + .map(|state| session_components_from_structure(state.session)) + .transpose()?; + let previous_sessions = Arc::try_unwrap(self.previous_sessions) + .unwrap_or_else(|shared| shared.as_ref().clone()) + .into_iter() + .map(session_components_from_structure) + .collect::, _>>()?; + + Ok(SessionRecordComponents { + current_session, + previous_sessions, + }) + } + pub fn reserved_sender_chain_index(&self) -> u32 { self.reserved_sender_chain_index } diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index fae040c67..c0aa5a641 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -251,6 +251,7 @@ pub enum EventKind { HistorySync, OfflineSyncPreview, OfflineSyncCompleted, + DirtyState, DeviceListUpdate, IdentityChange, BusinessStatusUpdate, @@ -711,6 +712,8 @@ pub enum Event { HistorySync(Box), OfflineSyncPreview(OfflineSyncPreview), OfflineSyncCompleted(OfflineSyncCompleted), + /// The server marked one of its cached protocol domains dirty. + DirtyState(DirtyState), /// Device list changed for a user (device added/removed/updated) DeviceListUpdate(DeviceListUpdate), @@ -846,6 +849,7 @@ impl Event { Event::HistorySync(_) => EventKind::HistorySync, Event::OfflineSyncPreview(_) => EventKind::OfflineSyncPreview, Event::OfflineSyncCompleted(_) => EventKind::OfflineSyncCompleted, + Event::DirtyState(_) => EventKind::DirtyState, Event::DeviceListUpdate(_) => EventKind::DeviceListUpdate, Event::IdentityChange(_) => EventKind::IdentityChange, Event::BusinessStatusUpdate(_) => EventKind::BusinessStatusUpdate, @@ -1188,6 +1192,19 @@ pub struct OfflineSyncCompleted { pub count: i32, } +/// A valid `` marker received from the server. +/// +/// The client still performs its built-in clean/resync work; this event lets +/// consumers refresh domain-specific derived state without observing every raw +/// stanza. +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct DirtyState { + pub dirty_type: crate::iq::dirty::DirtyType, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)] pub enum DecryptFailMode { #[wire = "show"] @@ -1595,6 +1612,23 @@ mod tests { assert!(!update.has_incomplete_participant_information); } + #[test] + fn dirty_state_preserves_wire_type_and_optional_timestamp() { + let dirty = DirtyState::builder() + .dirty_type(crate::iq::dirty::DirtyType::Groups) + .maybe_timestamp(Some(1_725_000_000)) + .build(); + + assert_eq!( + serde_json::to_value(&dirty).unwrap(), + serde_json::json!({ + "dirty_type": "groups", + "timestamp": 1_725_000_000_u64, + }) + ); + assert_eq!(Event::DirtyState(dirty).kind(), EventKind::DirtyState); + } + #[test] fn unavailable_fanout_flags_follow_wa_web_precedence() { use UnavailableType::*; From 7bdc6585b9f4f68e1c0a607239a258890e614997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:50:17 -0300 Subject: [PATCH 02/10] fix(signal): bound imported sender-key history --- .../src/protocol/record_components.rs | 17 ++++++++++ wacore/libsignal/src/protocol/sender_keys.rs | 34 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/wacore/libsignal/src/protocol/record_components.rs b/wacore/libsignal/src/protocol/record_components.rs index 0ccb8d39e..e5828c6d5 100644 --- a/wacore/libsignal/src/protocol/record_components.rs +++ b/wacore/libsignal/src/protocol/record_components.rs @@ -932,6 +932,23 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn sender_key_components_bound_state_history() { + let mut components = sender_key_record(); + let state = components.states[0].clone(); + components.states = vec![state; crate::protocol::consts::MAX_SENDER_KEY_STATES + 1]; + + let actual = SenderKeyRecord::from_components(components) + .expect("valid components") + .into_components() + .expect("project record"); + + assert_eq!( + actual.states.len(), + crate::protocol::consts::MAX_SENDER_KEY_STATES + ); + } + #[test] fn sender_key_handoff_burns_the_reserved_iteration_range() { let mut record = diff --git a/wacore/libsignal/src/protocol/sender_keys.rs b/wacore/libsignal/src/protocol/sender_keys.rs index ee2a7f65b..6113c74fa 100644 --- a/wacore/libsignal/src/protocol/sender_keys.rs +++ b/wacore/libsignal/src/protocol/sender_keys.rs @@ -536,11 +536,14 @@ impl SenderKeyRecord { /// Builds a record from validated protocol components. /// /// Components do not carry process-local durability metadata, so the - /// imported current chain starts a fresh reservation lifecycle. + /// imported current chain starts a fresh reservation lifecycle. Historical + /// states are bounded to the same limit enforced by record mutation and + /// deserialization. pub fn from_components(value: SenderKeyRecordComponents) -> Result { let states = value .states .into_iter() + .take(consts::MAX_SENDER_KEY_STATES) .map(sender_state_structure_from_components) .map(|state| state.map(SenderKeyState::from_protobuf)) .collect::, _>>()?; @@ -612,8 +615,16 @@ impl SenderKeyRecord { let skr = waproto::codec::sender_key_record_decode(buf) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let mut states = VecDeque::with_capacity(skr.sender_key_states.len()); - for state in skr.sender_key_states { + let mut states = VecDeque::with_capacity( + skr.sender_key_states + .len() + .min(consts::MAX_SENDER_KEY_STATES), + ); + for state in skr + .sender_key_states + .into_iter() + .take(consts::MAX_SENDER_KEY_STATES) + { // Validate seeds eagerly so callers get a clear error on corrupt data. if let Some(sender_chain) = state.sender_chain_key.as_option() { let _ = seed_to_array(sender_chain.seed.as_ref())?; @@ -1440,6 +1451,23 @@ mod tests { assert!(chain_ids.len() <= consts::MAX_SENDER_KEY_STATES); } + #[test] + fn test_sender_key_record_deserialize_bounds_state_history() { + let mut state = record_with_state(12345, 0x42).as_protobuf(); + let state = state.sender_key_states.pop().expect("test state"); + let encoded = SenderKeyRecordStructure { + sender_key_states: vec![state; consts::MAX_SENDER_KEY_STATES + 1], + } + .encode_to_vec(); + + let record = SenderKeyRecord::deserialize(&encoded).expect("valid record"); + + assert_eq!( + record.chain_ids_for_logging().len(), + consts::MAX_SENDER_KEY_STATES + ); + } + /// Test SenderKeyRecord chain ID lookup #[test] fn test_sender_key_record_chain_id_lookup() { From 7196edfbe99f246b752d4742fc716ea1f702c9ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:04:01 -0300 Subject: [PATCH 03/10] fix(signal): normalize canonical receiver chain state --- src/client/adapters.rs | 2 +- .../src/protocol/record_components.rs | 93 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/client/adapters.rs b/src/client/adapters.rs index e5d27026d..a5f8a863e 100644 --- a/src/client/adapters.rs +++ b/src/client/adapters.rs @@ -107,7 +107,7 @@ impl Client { self.signal_cache .flush(&*backend) .await - .map_err(|e| anyhow::anyhow!("Failed to flush signal cache: {e}")) + .map_err(|error| error.context("Failed to flush signal cache")) } /// Signal-cache flush that is safe while the offline drain is active. diff --git a/wacore/libsignal/src/protocol/record_components.rs b/wacore/libsignal/src/protocol/record_components.rs index e5828c6d5..ba25e72f6 100644 --- a/wacore/libsignal/src/protocol/record_components.rs +++ b/wacore/libsignal/src/protocol/record_components.rs @@ -415,6 +415,19 @@ impl SessionChainComponents { .collect::>()?, }) } + + fn from_receiver_structure(mut value: session_structure::Chain) -> Result { + // Receiver chains never own the remote ratchet's private key. The + // canonical session writer persists that absence as `Some([])`. + if value + .sender_ratchet_key_private + .as_ref() + .is_some_and(Vec::is_empty) + { + value.sender_ratchet_key_private = None; + } + Self::from_structure(value) + } } impl PendingKeyExchangeComponents { @@ -566,7 +579,7 @@ pub(crate) fn session_components_from_structure( receiver_chains: value .receiver_chains .into_iter() - .map(SessionChainComponents::from_structure) + .map(SessionChainComponents::from_receiver_structure) .collect::>()?, pending_key_exchange: value .pending_key_exchange @@ -693,7 +706,7 @@ pub(crate) fn sender_state_components_from_structure( #[cfg(test)] mod tests { use super::*; - use crate::protocol::{SenderKeyRecord, SessionRecord}; + use crate::protocol::{ChainKey, SenderKeyRecord, SessionRecord}; fn public_key(seed: u8) -> Vec { PublicKey::from_djb_public_key_bytes(&[seed; PublicKey::RAW_KEY_LEN]) @@ -866,6 +879,82 @@ mod tests { assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); } + #[test] + fn canonical_receiver_chain_private_key_sentinel_round_trips() { + let mut components = session_record(); + components + .current_session + .as_mut() + .expect("test session is present") + .receiver_chains + .clear(); + let mut record = SessionRecord::from_components(components).expect("valid record"); + let receiver_key = PublicKey::deserialize(&public_key(34)).expect("valid receiver key"); + record + .session_state_mut() + .expect("test session is present") + .add_receiver_chain(&receiver_key, &ChainKey::new([35; SYMMETRIC_KEY_BYTES], 0)); + + let persisted = record.serialize().expect("serialize record"); + let projected = SessionRecord::deserialize(&persisted) + .expect("deserialize canonical record") + .into_components() + .expect("project canonical record"); + let session = projected + .current_session + .as_ref() + .expect("test session is present"); + assert_eq!(session.receiver_chains[0].sender_ratchet_key_private, None); + assert_eq!( + session + .sender_chain + .as_ref() + .and_then(|chain| chain.sender_ratchet_key_private.as_ref()) + .map(Vec::len), + Some(PRIVATE_KEY_BYTES) + ); + + let rebuilt = SessionRecord::from_components(projected.clone()) + .expect("rebuild canonical record") + .serialize() + .expect("serialize rebuilt record"); + let reprojected = SessionRecord::deserialize(&rebuilt) + .expect("deserialize rebuilt record") + .into_components() + .expect("project rebuilt record"); + assert_eq!(reprojected, projected); + } + + #[test] + fn malformed_receiver_private_key_is_still_rejected() { + let session = session_record() + .current_session + .expect("test session is present"); + let mut persisted = session_structure_from_components(session).expect("valid session"); + persisted.receiver_chains[0].sender_ratchet_key_private = + Some(vec![0; PRIVATE_KEY_BYTES - 1]); + let error = + session_components_from_structure(persisted).expect_err("short private key must fail"); + + assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); + } + + #[test] + fn component_import_rejects_empty_private_key() { + let mut components = session_record(); + components + .current_session + .as_mut() + .expect("test session is present") + .receiver_chains[0] + .sender_ratchet_key_private = Some(Vec::new()); + + let error = SessionRecord::from_components(components) + .err() + .expect("component key material must remain strict"); + assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); + } + #[test] fn session_components_use_the_canonical_record_codec() { let expected = session_record(); From 115c8f21022291c6c9a1bbf44474d1dde0299ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:07:06 -0300 Subject: [PATCH 04/10] fix(signal): burn archived session reservations on handoff --- .../src/protocol/record_components.rs | 19 ++++++++++++++++--- .../libsignal/src/protocol/state/session.rs | 14 +++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/wacore/libsignal/src/protocol/record_components.rs b/wacore/libsignal/src/protocol/record_components.rs index ba25e72f6..69e5a978e 100644 --- a/wacore/libsignal/src/protocol/record_components.rs +++ b/wacore/libsignal/src/protocol/record_components.rs @@ -992,18 +992,31 @@ mod tests { } #[test] - fn session_handoff_burns_the_reserved_sender_range() { + fn session_handoff_burns_the_reserved_sender_range_in_all_states() { let mut record = SessionRecord::from_components(session_record()).expect("valid record"); record.reserve_sender_chain_counters(0); let ceiling = record.reserved_sender_chain_index(); let components = record.into_components().expect("safe handoff"); let index = components .current_session - .and_then(|session| session.sender_chain) - .and_then(|chain| chain.chain_key) + .as_ref() + .and_then(|session| session.sender_chain.as_ref()) + .and_then(|chain| chain.chain_key.as_ref()) .and_then(|chain| chain.index); + let archived_indexes: Vec<_> = components + .previous_sessions + .iter() + .map(|session| { + session + .sender_chain + .as_ref() + .and_then(|chain| chain.chain_key.as_ref()) + .and_then(|chain| chain.index) + }) + .collect(); assert_eq!(index, Some(ceiling)); + assert_eq!(archived_indexes, vec![Some(ceiling)]); } #[test] diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index bb85e0d59..fdf7d1a9a 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -747,10 +747,11 @@ impl SessionRecord { /// before export so rebuilding the record cannot derive a possibly spent /// message key again. pub fn into_components(mut self) -> Result { - if self.reserved_sender_chain_index > 0 + let reserved_sender_chain_index = self.reserved_sender_chain_index; + if reserved_sender_chain_index > 0 && let Some(state) = self.current_session.as_mut() { - state.fast_forward_sender_chain(self.reserved_sender_chain_index)?; + state.fast_forward_sender_chain(reserved_sender_chain_index)?; } let current_session = self .current_session @@ -759,7 +760,14 @@ impl SessionRecord { let previous_sessions = Arc::try_unwrap(self.previous_sessions) .unwrap_or_else(|shared| shared.as_ref().clone()) .into_iter() - .map(session_components_from_structure) + .map(|session| { + if reserved_sender_chain_index == 0 { + return session_components_from_structure(session); + } + let mut state = SessionState::from_session_structure(session); + state.fast_forward_sender_chain(reserved_sender_chain_index)?; + session_components_from_structure(state.session) + }) .collect::, _>>()?; Ok(SessionRecordComponents { From 0fc6a05149ef626b9c0b51b780c071cd0d9f9a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:33:48 -0300 Subject: [PATCH 05/10] fix(signal): enforce session chain roles at handoff --- .../src/protocol/record_components.rs | 386 +++++++++++++++--- 1 file changed, 322 insertions(+), 64 deletions(-) diff --git a/wacore/libsignal/src/protocol/record_components.rs b/wacore/libsignal/src/protocol/record_components.rs index 69e5a978e..e1e47cb96 100644 --- a/wacore/libsignal/src/protocol/record_components.rs +++ b/wacore/libsignal/src/protocol/record_components.rs @@ -48,6 +48,10 @@ pub struct SessionComponents { } /// A sender or receiver ratchet chain and its skipped message keys. +/// +/// Imported sender chains must be structurally complete. Imported receiver +/// chains must not carry private material; persisted receiver private fields +/// are ignored for compatibility with the canonical reader. #[derive(Clone, PartialEq, Eq, Default)] pub struct SessionChainComponents { pub sender_ratchet_key: Option>, @@ -257,6 +261,10 @@ fn optional_public_key(value: Option>, field: &'static str) -> Result>, field: &'static str) -> Result> { + normalize_public_key(value.ok_or_else(|| invalid(field, "present"))?, field) +} + fn exact_bytes(value: Vec, length: usize, field: &'static str) -> Result> { if value.len() == length { Ok(value) @@ -277,6 +285,18 @@ fn optional_exact_bytes( .transpose() } +fn required_exact_bytes( + value: Option>, + length: usize, + field: &'static str, +) -> Result> { + exact_bytes( + value.ok_or_else(|| invalid(field, "present"))?, + length, + field, + ) +} + impl SessionMessageKeyComponents { fn into_structure(self) -> Result { match self.material { @@ -365,20 +385,76 @@ impl SessionChainKeyComponents { )?, }) } + + fn into_required_structure(self) -> Result { + Ok(session_structure::chain::ChainKey { + index: Some( + self.index + .ok_or_else(|| invalid("sender chain-key index", "present"))?, + ), + key: Some(Bytes::from(required_exact_bytes( + self.key, + SYMMETRIC_KEY_BYTES, + "sender chain key", + )?)), + }) + } + + fn from_required_structure(value: session_structure::chain::ChainKey) -> Result { + Ok(Self { + index: Some( + value + .index + .ok_or_else(|| invalid("sender chain-key index", "present"))?, + ), + key: Some(required_exact_bytes( + value.key.map(|value| value.to_vec()), + SYMMETRIC_KEY_BYTES, + "sender chain key", + )?), + }) + } } impl SessionChainComponents { - fn into_structure(self) -> Result { + fn into_sender_structure(self) -> Result { Ok(session_structure::Chain { - sender_ratchet_key: optional_public_key( + sender_ratchet_key: Some(required_public_key( self.sender_ratchet_key, - "session ratchet public key", - )?, - sender_ratchet_key_private: optional_exact_bytes( + "sender ratchet public key", + )?), + sender_ratchet_key_private: Some(required_exact_bytes( self.sender_ratchet_key_private, PRIVATE_KEY_BYTES, - "session ratchet private key", + "sender ratchet private key", + )?), + chain_key: MessageField::some( + self.chain_key + .ok_or_else(|| invalid("sender chain key", "present"))? + .into_required_structure()?, + ), + message_keys: self + .message_keys + .into_iter() + .map(SessionMessageKeyComponents::into_structure) + .collect::>()?, + }) + } + + fn into_receiver_structure(self) -> Result { + if self.sender_ratchet_key_private.is_some() { + return Err(invalid( + "receiver ratchet private key", + "absent from receiver chains", + )); + } + + Ok(session_structure::Chain { + sender_ratchet_key: optional_public_key( + self.sender_ratchet_key, + "receiver ratchet public key", )?, + sender_ratchet_key_private: None, chain_key: self .chain_key .map(SessionChainKeyComponents::into_structure) @@ -392,17 +468,41 @@ impl SessionChainComponents { }) } - fn from_structure(mut value: session_structure::Chain) -> Result { + fn from_sender_structure(mut value: session_structure::Chain) -> Result { Ok(Self { - sender_ratchet_key: optional_public_key( + sender_ratchet_key: Some(required_public_key( value.sender_ratchet_key, - "session ratchet public key", - )?, - sender_ratchet_key_private: optional_exact_bytes( + "sender ratchet public key", + )?), + sender_ratchet_key_private: Some(required_exact_bytes( value.sender_ratchet_key_private, PRIVATE_KEY_BYTES, - "session ratchet private key", + "sender ratchet private key", + )?), + chain_key: Some(SessionChainKeyComponents::from_required_structure( + value + .chain_key + .take() + .ok_or_else(|| invalid("sender chain key", "present"))?, + )?), + message_keys: value + .message_keys + .into_iter() + .map(SessionMessageKeyComponents::from_structure) + .collect::>()?, + }) + } + + fn from_receiver_structure(mut value: session_structure::Chain) -> Result { + // The official reader ignores this field for receiver chains, including + // historical non-canonical values. + value.sender_ratchet_key_private = None; + Ok(Self { + sender_ratchet_key: optional_public_key( + value.sender_ratchet_key, + "receiver ratchet public key", )?, + sender_ratchet_key_private: None, chain_key: value .chain_key .take() @@ -415,19 +515,6 @@ impl SessionChainComponents { .collect::>()?, }) } - - fn from_receiver_structure(mut value: session_structure::Chain) -> Result { - // Receiver chains never own the remote ratchet's private key. The - // canonical session writer persists that absence as `Some([])`. - if value - .sender_ratchet_key_private - .as_ref() - .is_some_and(Vec::is_empty) - { - value.sender_ratchet_key_private = None; - } - Self::from_structure(value) - } } impl PendingKeyExchangeComponents { @@ -531,13 +618,13 @@ pub(crate) fn session_structure_from_components( previous_counter: value.previous_counter, sender_chain: value .sender_chain - .map(SessionChainComponents::into_structure) + .map(SessionChainComponents::into_sender_structure) .transpose()? .into(), receiver_chains: value .receiver_chains .into_iter() - .map(SessionChainComponents::into_structure) + .map(SessionChainComponents::into_receiver_structure) .collect::>()?, pending_key_exchange: value .pending_key_exchange @@ -574,7 +661,7 @@ pub(crate) fn session_components_from_structure( sender_chain: value .sender_chain .take() - .map(SessionChainComponents::from_structure) + .map(SessionChainComponents::from_sender_structure) .transpose()?, receiver_chains: value .receiver_chains @@ -706,7 +793,19 @@ pub(crate) fn sender_state_components_from_structure( #[cfg(test)] mod tests { use super::*; - use crate::protocol::{ChainKey, SenderKeyRecord, SessionRecord}; + use crate::protocol::{ + ChainKey, IdentityKey, KeyPair, RootKey, SenderKeyRecord, SessionRecord, SessionState, + }; + + #[derive(Debug, Clone, Copy)] + enum SenderChainFault { + MissingPrivateKey, + PrivateKeyLength(usize), + MissingPublicKey, + MissingChainKey, + MissingChainKeyIndex, + MissingChainKeySecret, + } fn public_key(seed: u8) -> Vec { PublicKey::from_djb_public_key_bytes(&[seed; PublicKey::RAW_KEY_LEN]) @@ -715,6 +814,106 @@ mod tests { .to_vec() } + fn canonical_session_state() -> SessionState { + let mut rng = rand::make_rng::(); + let local_identity = IdentityKey::new(KeyPair::generate(&mut rng).public_key); + let remote_identity = IdentityKey::new(KeyPair::generate(&mut rng).public_key); + let base_key = KeyPair::generate(&mut rng).public_key; + let mut state = SessionState::new( + 3, + &local_identity, + &remote_identity, + &RootKey::new([3; SYMMETRIC_KEY_BYTES]), + &base_key, + ); + state.set_sender_chain( + &KeyPair::generate(&mut rng), + &ChainKey::new([4; SYMMETRIC_KEY_BYTES], 5), + ); + state.add_receiver_chain( + &KeyPair::generate(&mut rng).public_key, + &ChainKey::new([6; SYMMETRIC_KEY_BYTES], 7), + ); + state + } + + fn canonical_session_components() -> SessionRecordComponents { + SessionRecord::new(canonical_session_state()) + .into_components() + .expect("canonical session projects") + } + + fn sender_chain_faults() -> [SenderChainFault; 8] { + [ + SenderChainFault::MissingPrivateKey, + SenderChainFault::PrivateKeyLength(0), + SenderChainFault::PrivateKeyLength(PRIVATE_KEY_BYTES - 1), + SenderChainFault::PrivateKeyLength(PRIVATE_KEY_BYTES + 1), + SenderChainFault::MissingPublicKey, + SenderChainFault::MissingChainKey, + SenderChainFault::MissingChainKeyIndex, + SenderChainFault::MissingChainKeySecret, + ] + } + + fn corrupt_sender_components( + components: &mut SessionRecordComponents, + fault: SenderChainFault, + ) { + let sender = components + .current_session + .as_mut() + .and_then(|session| session.sender_chain.as_mut()) + .expect("canonical sender chain"); + match fault { + SenderChainFault::MissingPrivateKey => sender.sender_ratchet_key_private = None, + SenderChainFault::PrivateKeyLength(length) => { + sender.sender_ratchet_key_private = Some(vec![0; length]); + } + SenderChainFault::MissingPublicKey => sender.sender_ratchet_key = None, + SenderChainFault::MissingChainKey => sender.chain_key = None, + SenderChainFault::MissingChainKeyIndex => { + sender + .chain_key + .as_mut() + .expect("canonical chain key") + .index = None; + } + SenderChainFault::MissingChainKeySecret => { + sender.chain_key.as_mut().expect("canonical chain key").key = None; + } + } + } + + fn corrupt_persisted_sender(session: &mut SessionStructure, fault: SenderChainFault) { + let sender = session + .sender_chain + .as_option_mut() + .expect("canonical sender chain"); + match fault { + SenderChainFault::MissingPrivateKey => sender.sender_ratchet_key_private = None, + SenderChainFault::PrivateKeyLength(length) => { + sender.sender_ratchet_key_private = Some(vec![0; length]); + } + SenderChainFault::MissingPublicKey => sender.sender_ratchet_key = None, + SenderChainFault::MissingChainKey => sender.chain_key = MessageField::none(), + SenderChainFault::MissingChainKeyIndex => { + sender + .chain_key + .as_option_mut() + .expect("canonical chain key") + .index = None; + } + SenderChainFault::MissingChainKeySecret => { + sender + .chain_key + .as_option_mut() + .expect("canonical chain key") + .key = None; + } + } + } + fn session_record() -> SessionRecordComponents { let current_session = SessionComponents { session_version: Some(3), @@ -880,22 +1079,25 @@ mod tests { } #[test] - fn canonical_receiver_chain_private_key_sentinel_round_trips() { - let mut components = session_record(); - components - .current_session - .as_mut() - .expect("test session is present") - .receiver_chains - .clear(); - let mut record = SessionRecord::from_components(components).expect("valid record"); - let receiver_key = PublicKey::deserialize(&public_key(34)).expect("valid receiver key"); - record - .session_state_mut() - .expect("test session is present") - .add_receiver_chain(&receiver_key, &ChainKey::new([35; SYMMETRIC_KEY_BYTES], 0)); - - let persisted = record.serialize().expect("serialize record"); + fn canonical_session_api_round_trips_through_components() { + let state = canonical_session_state(); + let structure = SessionStructure::from(&state); + assert_eq!( + structure.receiver_chains[0].sender_ratchet_key_private, + Some(Vec::new()) + ); + assert_eq!( + structure + .sender_chain + .as_option() + .and_then(|chain| chain.sender_ratchet_key_private.as_ref()) + .map(Vec::len), + Some(PRIVATE_KEY_BYTES) + ); + + let persisted = SessionRecord::new(state) + .serialize() + .expect("serialize canonical record"); let projected = SessionRecord::deserialize(&persisted) .expect("deserialize canonical record") .into_components() @@ -926,33 +1128,89 @@ mod tests { } #[test] - fn malformed_receiver_private_key_is_still_rejected() { - let session = session_record() - .current_session - .expect("test session is present"); - let mut persisted = session_structure_from_components(session).expect("valid session"); - persisted.receiver_chains[0].sender_ratchet_key_private = - Some(vec![0; PRIVATE_KEY_BYTES - 1]); - let error = - session_components_from_structure(persisted).expect_err("short private key must fail"); + fn persisted_receiver_private_material_is_ignored() { + for (case, private_key) in [ + ("absent", None), + ("empty", Some(Vec::new())), + ("one byte", Some(vec![0; 1])), + ("short key", Some(vec![0; PRIVATE_KEY_BYTES - 1])), + ("key-sized", Some(vec![0; PRIVATE_KEY_BYTES])), + ("long key", Some(vec![0; PRIVATE_KEY_BYTES + 1])), + ] { + let mut persisted = SessionStructure::from(canonical_session_state()); + persisted.receiver_chains[0].sender_ratchet_key_private = private_key; + + let projected = session_components_from_structure(persisted).unwrap_or_else(|error| { + panic!("{case} receiver material must be ignored: {error}") + }); + assert_eq!( + projected.receiver_chains[0].sender_ratchet_key_private, None, + "{case}" + ); + } + } - assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); + #[test] + fn receiver_component_import_rejects_private_material() { + for private_key in [Vec::new(), vec![0; PRIVATE_KEY_BYTES]] { + let mut components = canonical_session_components(); + components + .current_session + .as_mut() + .expect("canonical session") + .receiver_chains[0] + .sender_ratchet_key_private = Some(private_key); + + let error = SessionRecord::from_components(components) + .err() + .expect("receiver components must not carry private material"); + assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); + } } #[test] - fn component_import_rejects_empty_private_key() { - let mut components = session_record(); + fn sender_component_import_requires_a_complete_chain() { + for fault in sender_chain_faults() { + let mut components = canonical_session_components(); + corrupt_sender_components(&mut components, fault); + + let error = SessionRecord::from_components(components) + .err() + .unwrap_or_else(|| panic!("sender fault {fault:?} must fail")); + assert!( + matches!(error, SignalProtocolError::InvalidArgument(_)), + "{fault:?}: {error}" + ); + } + } + + #[test] + fn persisted_sender_projection_requires_a_complete_chain() { + for fault in sender_chain_faults() { + let mut persisted = SessionStructure::from(canonical_session_state()); + corrupt_persisted_sender(&mut persisted, fault); + + let error = session_components_from_structure(persisted) + .err() + .unwrap_or_else(|| panic!("persisted sender fault {fault:?} must fail")); + assert!( + matches!(error, SignalProtocolError::InvalidArgument(_)), + "{fault:?}: {error}" + ); + } + } + + #[test] + fn component_import_allows_an_absent_sender_chain() { + let mut components = canonical_session_components(); components .current_session .as_mut() - .expect("test session is present") - .receiver_chains[0] - .sender_ratchet_key_private = Some(Vec::new()); + .expect("canonical session") + .sender_chain = None; - let error = SessionRecord::from_components(components) - .err() - .expect("component key material must remain strict"); - assert!(matches!(error, SignalProtocolError::InvalidArgument(_))); + SessionRecord::from_components(components) + .expect("intermediate state without sender chain"); } #[test] From 5504c295795db1b03c006b7ef80d42e86823a500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:36:03 -0300 Subject: [PATCH 06/10] fix(signal): validate sender chain usability --- .../libsignal/src/protocol/state/session.rs | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index fdf7d1a9a..712968759 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -247,8 +247,10 @@ impl SessionState { if self.session.sender_chain.is_unset() { return Ok(false); } - // We removed timestamp from PendingPreKey, so we can't check for expiration here. - // Assuming it's valid if it exists. + + self.sender_ratchet_key()?; + self.sender_ratchet_private_key()?; + self.get_sender_chain_key()?; Ok(true) } @@ -1296,6 +1298,76 @@ mod tests { state } + fn assert_malformed_sender_chain( + state: &SessionState, + expected_error: &str, + mutate: impl FnOnce(&mut session_structure::Chain), + ) { + let mut structure = SessionStructure::from(state); + mutate( + structure + .sender_chain + .as_option_mut() + .expect("test sender chain"), + ); + let state = SessionState::from(structure); + assert_eq!( + state + .has_usable_sender_chain() + .expect_err("malformed sender chain must fail") + .to_string(), + expected_error + ); + } + + #[test] + fn complete_sender_chain_is_usable() { + let base_key = KeyPair::generate(&mut rng()).public_key; + let state = create_test_session_state(3, &base_key); + + assert!(state.has_usable_sender_chain().unwrap()); + } + + #[test] + fn present_sender_chain_must_be_structurally_usable() { + let base_key = KeyPair::generate(&mut rng()).public_key; + let state = create_test_session_state(3, &base_key); + + assert_malformed_sender_chain(&state, "missing sender ratchet key", |chain| { + chain.sender_ratchet_key = None; + }); + assert_malformed_sender_chain(&state, "invalid sender chain ratchet key", |chain| { + chain.sender_ratchet_key = Some(Vec::new()); + }); + assert_malformed_sender_chain(&state, "missing sender ratchet private key", |chain| { + chain.sender_ratchet_key_private = None; + }); + assert_malformed_sender_chain( + &state, + "invalid sender chain private ratchet key", + |chain| { + chain.sender_ratchet_key_private = Some(vec![0; 31]); + }, + ); + assert_malformed_sender_chain(&state, "missing sender chain key", |chain| { + chain.chain_key = MessageField::none(); + }); + assert_malformed_sender_chain(&state, "missing sender chain key index", |chain| { + chain + .chain_key + .as_option_mut() + .expect("test chain key") + .index = None; + }); + assert_malformed_sender_chain(&state, "missing sender chain key bytes", |chain| { + chain.chain_key.as_option_mut().expect("test chain key").key = None; + }); + assert_malformed_sender_chain(&state, "invalid sender chain key", |chain| { + chain.chain_key.as_option_mut().expect("test chain key").key = + Some(bytes::Bytes::from_static(&[0; 31])); + }); + } + #[test] fn set_sender_chain_key_requires_existing_sender_chain() { let mut csprng = rng(); From 95d92f6a8e77e7d6bdfb61f69d672e23300cb8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:38:07 -0300 Subject: [PATCH 07/10] fix(signal): drop stale archived chains on handoff --- .../libsignal/src/protocol/state/session.rs | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index 712968759..a219533cc 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -471,6 +471,13 @@ impl SessionState { Ok(()) } + fn fast_forward_sender_chain_or_drop(&mut self, target: u32) { + if let Err(error) = self.fast_forward_sender_chain(target) { + log::error!("dropping unusable sender chain: {error}"); + self.session.sender_chain = MessageField::none(); + } + } + pub fn get_message_keys( &mut self, sender: &PublicKey, @@ -767,7 +774,7 @@ impl SessionRecord { return session_components_from_structure(session); } let mut state = SessionState::from_session_structure(session); - state.fast_forward_sender_chain(reserved_sender_chain_index)?; + state.fast_forward_sender_chain_or_drop(reserved_sender_chain_index); session_components_from_structure(state.session) }) .collect::, _>>()?; @@ -1009,13 +1016,8 @@ impl SessionRecord { pub fn promote_state(&mut self, new_state: SessionState) { self.archive_current_state_inner(); let mut state = new_state; - if self.reserved_sender_chain_index > 0 - && let Err(e) = state.fast_forward_sender_chain(self.reserved_sender_chain_index) - { - // Only reachable with a corrupt reservation (gap beyond the - // ceiling); refuse to expose the chain rather than risk reuse. - log::error!("dropping promoted sender chain: {e}"); - state.session.sender_chain = None.into(); + if self.reserved_sender_chain_index > 0 { + state.fast_forward_sender_chain_or_drop(self.reserved_sender_chain_index); } self.current_session = Some(state); } @@ -1414,6 +1416,39 @@ mod tests { ); } + #[test] + fn component_handoff_drops_a_stale_archived_sender_chain() { + let mut csprng = rng(); + let archived_base_key = KeyPair::generate(&mut csprng).public_key; + let archived = create_test_session_state(3, &archived_base_key); + let current_base_key = KeyPair::generate(&mut csprng).public_key; + let mut current = create_test_session_state(3, ¤t_base_key); + let spent_counter = consts::MAX_RESERVATION_FAST_FORWARD + 1; + current + .set_sender_chain_key(&ChainKey::new([9; 32], spent_counter)) + .expect("current sender chain"); + + let mut record = SessionRecord::new(archived); + record.promote_fresh_state(current); + record.reserve_sender_chain_counters(spent_counter); + let reserved = record.reserved_sender_chain_index(); + + let components = record + .into_components() + .expect("stale archive must not block handoff"); + assert_eq!( + components + .current_session + .as_ref() + .and_then(|session| session.sender_chain.as_ref()) + .and_then(|chain| chain.chain_key.as_ref()) + .and_then(|chain_key| chain_key.index), + Some(reserved) + ); + assert_eq!(components.previous_sessions.len(), 1); + assert!(components.previous_sessions[0].sender_chain.is_none()); + } + /// A freshly ratcheted chain has never spent a counter, so promoting it /// resets the lease instead of burning it (the first send re-reserves). #[test] From c213e3f782ed6966c2da03b6757f96b2fd97d01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:40:15 -0300 Subject: [PATCH 08/10] test(signal): preserve cache flush error causes --- src/client/adapters.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/client/adapters.rs b/src/client/adapters.rs index a5f8a863e..20b1da457 100644 --- a/src/client/adapters.rs +++ b/src/client/adapters.rs @@ -212,3 +212,36 @@ fn log_signal_flush_error(context: &str, id: Option<&str>, e: &anyhow::Error) { log::error!("Failed to flush signal cache ({context}): {e:?}"); } } + +#[cfg(test)] +mod tests { + use super::*; + use wacore::store::in_memory::InMemoryBackend; + use wacore_binary::{Jid, Server}; + + #[tokio::test] + async fn signal_flush_context_preserves_the_backend_error_chain() { + let backend = Arc::new(InMemoryBackend::new()); + let client = crate::test_utils::create_test_client_with_backend(backend.clone()).await; + let peer = Jid::new("15550001111", Server::Pn).with_device(1); + crate::test_utils::seed_peer_session(&client, &peer).await; + backend.set_fail_session_writes(true); + + let error = client + .flush_signal_cache() + .await + .expect_err("injected backend failure must propagate"); + let chain: Vec = error.chain().map(ToString::to_string).collect(); + + assert_eq!( + chain.first().map(String::as_str), + Some("Failed to flush signal cache") + ); + assert!( + chain + .iter() + .any(|cause| cause.contains("put_sessions_batch failing (test hook)")), + "typed backend cause missing from {chain:?}" + ); + } +} From edb0e8077c7ff3835d1de7d70ae0d326081f1be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:51:58 -0300 Subject: [PATCH 09/10] fix(signal): preserve archived session leases --- .../libsignal/src/protocol/state/session.rs | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index a219533cc..ad62b27e9 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -1024,10 +1024,15 @@ impl SessionRecord { /// Make a freshly ratcheted state current. Its sender chain key material /// was just generated from a fresh random ephemeral, so no counter on it - /// can ever have been spent: the inherited lease is meaningless for it and - /// is reset instead of burned. The first send re-reserves durably before - /// hitting the wire. + /// can ever have been spent. Burn the inherited lease into the state being + /// archived before resetting it for the fresh chain; otherwise the archive + /// could later reissue a counter covered by the discarded lease. pub fn promote_fresh_state(&mut self, new_state: SessionState) { + if self.reserved_sender_chain_index > 0 + && let Some(state) = self.current_session.as_mut() + { + state.fast_forward_sender_chain_or_drop(self.reserved_sender_chain_index); + } self.archive_current_state_inner(); self.current_session = Some(new_state); self.reserved_sender_chain_index = 0; @@ -1449,18 +1454,21 @@ mod tests { assert!(components.previous_sessions[0].sender_chain.is_none()); } - /// A freshly ratcheted chain has never spent a counter, so promoting it - /// resets the lease instead of burning it (the first send re-reserves). + /// A freshly ratcheted chain starts at zero, while the state being archived + /// must retain the safety provided by the lease that is about to be reset. #[test] - fn promote_fresh_state_resets_the_lease() { + fn promote_fresh_state_retires_the_archived_lease_before_reset() { let mut csprng = rng(); - let base_key = KeyPair::generate(&mut csprng).public_key; - let state = create_test_session_state(3, &base_key); + let archived_base_key = KeyPair::generate(&mut csprng).public_key; + let archived = create_test_session_state(3, &archived_base_key); + let fresh_base_key = KeyPair::generate(&mut csprng).public_key; + let fresh = create_test_session_state(3, &fresh_base_key); - let mut record = SessionRecord::new_fresh(); - record.reserve_sender_chain_counters(500); + let mut record = SessionRecord::new(archived); + record.reserve_sender_chain_counters(0); + let retired_ceiling = record.reserved_sender_chain_index(); - record.promote_fresh_state(state); + record.promote_fresh_state(fresh); assert_eq!(record.reserved_sender_chain_index(), 0); let chain = record .session_state() @@ -1468,6 +1476,14 @@ mod tests { .get_sender_chain_key() .unwrap(); assert_eq!(chain.index(), 0, "a fresh chain must not be burned"); + + let components = record.into_components().expect("safe handoff"); + let archived_index = components.previous_sessions[0] + .sender_chain + .as_ref() + .and_then(|chain| chain.chain_key.as_ref()) + .and_then(|chain_key| chain_key.index); + assert_eq!(archived_index, Some(retired_ceiling)); } /// A corrupt reservation absurdly far ahead of the chain must be refused From d3f7d79c224828cfba46a6dd8539ec276ddd9292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:58:39 -0300 Subject: [PATCH 10/10] fix(signal): preserve records on stale handoff --- src/client/adapters.rs | 5 +++-- .../src/protocol/record_components.rs | 21 +++++++++++++++++++ .../libsignal/src/protocol/state/session.rs | 5 +++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/client/adapters.rs b/src/client/adapters.rs index 20b1da457..d8ca50d81 100644 --- a/src/client/adapters.rs +++ b/src/client/adapters.rs @@ -1,6 +1,7 @@ //! Signal/sender-key store adapters, per-session locks and noise socket access. use super::*; +use anyhow::Context as _; impl Client { /// Build a [`SignalProtocolStoreAdapter`] from the current device state and signal cache. @@ -107,7 +108,7 @@ impl Client { self.signal_cache .flush(&*backend) .await - .map_err(|error| error.context("Failed to flush signal cache")) + .context("Failed to flush signal cache") } /// Signal-cache flush that is safe while the offline drain is active. @@ -223,7 +224,7 @@ mod tests { async fn signal_flush_context_preserves_the_backend_error_chain() { let backend = Arc::new(InMemoryBackend::new()); let client = crate::test_utils::create_test_client_with_backend(backend.clone()).await; - let peer = Jid::new("15550001111", Server::Pn).with_device(1); + let peer = Jid::new("12025550111", Server::Pn).with_device(1); crate::test_utils::seed_peer_session(&client, &peer).await; backend.set_fail_session_writes(true); diff --git a/wacore/libsignal/src/protocol/record_components.rs b/wacore/libsignal/src/protocol/record_components.rs index e1e47cb96..d331505d4 100644 --- a/wacore/libsignal/src/protocol/record_components.rs +++ b/wacore/libsignal/src/protocol/record_components.rs @@ -1277,6 +1277,27 @@ mod tests { assert_eq!(archived_indexes, vec![Some(ceiling)]); } + #[test] + fn session_handoff_drops_unadvanceable_chains_without_losing_the_record() { + let mut record = SessionRecord::from_components(session_record()).expect("valid record"); + record.reserve_sender_chain_counters( + crate::protocol::consts::MAX_RESERVATION_FAST_FORWARD + 1, + ); + + let components = record + .into_components() + .expect("unadvanceable chains must not consume the record on error"); + + assert!( + components + .current_session + .as_ref() + .is_some_and(|session| session.sender_chain.is_none()) + ); + assert_eq!(components.previous_sessions.len(), 1); + assert!(components.previous_sessions[0].sender_chain.is_none()); + } + #[test] fn sender_key_components_use_the_canonical_record_codec() { let expected = sender_key_record(); diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index ad62b27e9..8434a9405 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -754,13 +754,14 @@ impl SessionRecord { /// /// Any durably reserved sender range is advanced to its exclusive ceiling /// before export so rebuilding the record cannot derive a possibly spent - /// message key again. + /// message key again. A chain too stale to advance is dropped fail-closed + /// without discarding the rest of the record. pub fn into_components(mut self) -> Result { let reserved_sender_chain_index = self.reserved_sender_chain_index; if reserved_sender_chain_index > 0 && let Some(state) = self.current_session.as_mut() { - state.fast_forward_sender_chain(reserved_sender_chain_index)?; + state.fast_forward_sender_chain_or_drop(reserved_sender_chain_index); } let current_session = self .current_session