lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 1572e683bae81bf4fcf48fda62068eeb79432d01
parent 2b931df539f4f5d0837f13cbd3b15f97e5db02b2
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 00:34:25 +0000

simplex: persist official skipped message keys

- track skipped official message keys for out-of-order decrypts
- bound skipped-message windows with explicit ratchet errors
- export skipped-message key state for agent persistence
- cover skipped payload replay, overflow, and store snapshot behavior

Diffstat:
Mcrates/simplex_agent_store/src/store.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/simplex_smp_crypto/src/error.rs | 7+++++++
Mcrates/simplex_smp_crypto/src/lib.rs | 2+-
Mcrates/simplex_smp_crypto/src/ratchet.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 325 insertions(+), 11 deletions(-)

diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs @@ -10,7 +10,10 @@ use radroots_simplex_agent_proto::prelude::{ RadrootsSimplexSmpRatchetState, decode_connection_link, decode_envelope, encode_connection_link, encode_envelope, }; -use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpEd25519Keypair; +use radroots_simplex_smp_crypto::prelude::{ + RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH, RadrootsSimplexSmpEd25519Keypair, + RadrootsSimplexSmpSkippedMessageKey, +}; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpServerAddress, }; @@ -238,6 +241,16 @@ struct RadrootsSimplexAgentRatchetStateSnapshot { official_receiving_header_key: Option<Vec<u8>>, official_next_sending_header_key: Option<Vec<u8>>, official_next_receiving_header_key: Option<Vec<u8>>, + official_skipped_message_keys: Vec<RadrootsSimplexAgentSkippedMessageKeySnapshot>, +} + +#[cfg(feature = "std")] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RadrootsSimplexAgentSkippedMessageKeySnapshot { + header_key: Vec<u8>, + message_number: u32, + message_key: Vec<u8>, + message_iv: Vec<u8>, } #[cfg(feature = "std")] @@ -1051,6 +1064,11 @@ fn ratchet_state_to_snapshot( official_receiving_header_key: state.official_receiving_header_key, official_next_sending_header_key: state.official_next_sending_header_key, official_next_receiving_header_key: state.official_next_receiving_header_key, + official_skipped_message_keys: state + .official_skipped_message_keys + .into_iter() + .map(skipped_message_key_to_snapshot) + .collect(), } } @@ -1103,10 +1121,48 @@ fn ratchet_state_from_snapshot( state.official_receiving_header_key = snapshot.official_receiving_header_key; state.official_next_sending_header_key = snapshot.official_next_sending_header_key; state.official_next_receiving_header_key = snapshot.official_next_receiving_header_key; + state.official_skipped_message_keys = snapshot + .official_skipped_message_keys + .into_iter() + .map(skipped_message_key_from_snapshot) + .collect::<Result<_, _>>()?; Ok(state) } #[cfg(feature = "std")] +fn skipped_message_key_to_snapshot( + key: RadrootsSimplexSmpSkippedMessageKey, +) -> RadrootsSimplexAgentSkippedMessageKeySnapshot { + RadrootsSimplexAgentSkippedMessageKeySnapshot { + header_key: key.header_key, + message_number: key.message_number, + message_key: key.message_key, + message_iv: key.message_iv.to_vec(), + } +} + +#[cfg(feature = "std")] +fn skipped_message_key_from_snapshot( + snapshot: RadrootsSimplexAgentSkippedMessageKeySnapshot, +) -> Result<RadrootsSimplexSmpSkippedMessageKey, RadrootsSimplexAgentStoreError> { + let message_iv: [u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH] = snapshot + .message_iv + .try_into() + .map_err(|message_iv: Vec<u8>| { + RadrootsSimplexAgentStoreError::Persistence(format!( + "invalid SimpleX skipped message IV length {}", + message_iv.len() + )) + })?; + Ok(RadrootsSimplexSmpSkippedMessageKey { + header_key: snapshot.header_key, + message_number: snapshot.message_number, + message_key: snapshot.message_key, + message_iv, + }) +} + +#[cfg(feature = "std")] fn command_to_snapshot( command: RadrootsSimplexAgentPendingCommand, ) -> Result<RadrootsSimplexAgentPendingCommandSnapshot, RadrootsSimplexAgentStoreError> { @@ -1520,6 +1576,14 @@ mod tests { ratchet.official_next_sending_header_key = Some(b"official-next-send-header".to_vec()); ratchet.official_next_receiving_header_key = Some(b"official-next-recv-header".to_vec()); + ratchet + .official_skipped_message_keys + .push(RadrootsSimplexSmpSkippedMessageKey { + header_key: b"official-skipped-header".to_vec(), + message_number: 7, + message_key: b"official-skipped-message".to_vec(), + message_iv: [3_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH], + }); connection.ratchet_state = Some(ratchet); connection.local_x3dh_key_1 = Some(RadrootsSimplexAgentX3dhKeypair { public_key: b"x3dh-public-1".to_vec(), @@ -1557,6 +1621,15 @@ mod tests { Some(&b"official-next-recv-header"[..]) ); assert_eq!( + loaded_ratchet.official_skipped_message_keys, + vec![RadrootsSimplexSmpSkippedMessageKey { + header_key: b"official-skipped-header".to_vec(), + message_number: 7, + message_key: b"official-skipped-message".to_vec(), + message_iv: [3_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH], + }] + ); + assert_eq!( loaded_connection .local_x3dh_key_1 .as_ref() diff --git a/crates/simplex_smp_crypto/src/error.rs b/crates/simplex_smp_crypto/src/error.rs @@ -10,6 +10,7 @@ pub enum RadrootsSimplexSmpCryptoError { MissingRatchetKey(&'static str), IncompletePqHeader, RatchetMessageRegression { received: u32, current: u32 }, + RatchetTooManySkipped { skipped: u32, max: u32 }, InvalidSharedSecretLength(usize), InvalidCiphertextLength(usize), InvalidNonceLength(usize), @@ -58,6 +59,12 @@ impl fmt::Display for RadrootsSimplexSmpCryptoError { "SMP ratchet message regression: received {received}, current {current}" ) } + Self::RatchetTooManySkipped { skipped, max } => { + write!( + f, + "SMP ratchet skipped {skipped} messages, exceeding maximum {max}" + ) + } Self::InvalidSharedSecretLength(length) => { write!(f, "invalid SMP shared secret length {length}") } diff --git a/crates/simplex_smp_crypto/src/lib.rs b/crates/simplex_smp_crypto/src/lib.rs @@ -57,6 +57,6 @@ pub mod prelude { }; pub use crate::ratchet::{ RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetRole, - RadrootsSimplexSmpRatchetState, + RadrootsSimplexSmpRatchetState, RadrootsSimplexSmpSkippedMessageKey, }; } diff --git a/crates/simplex_smp_crypto/src/ratchet.rs b/crates/simplex_smp_crypto/src/ratchet.rs @@ -4,15 +4,16 @@ use crate::message::{ encrypt_padded, }; use crate::official_ratchet::{ - RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH, - RadrootsSimplexOfficialAesGcmPayload, RadrootsSimplexOfficialEncryptedHeader, - RadrootsSimplexOfficialEncryptedMessage, RadrootsSimplexOfficialMsgHeader, - RadrootsSimplexOfficialX3dhInit, decode_official_encrypted_header, - decode_official_encrypted_message, decode_official_msg_header, - derive_official_x448_shared_secret, encode_official_encrypted_header, - encode_official_encrypted_message, encode_official_msg_header, generate_official_x448_keypair, - official_aes_gcm_decrypt_padded, official_aes_gcm_encrypt_padded, official_chain_kdf, - official_ratchet_header_len, official_root_kdf, + RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH, RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH, RadrootsSimplexOfficialAesGcmPayload, + RadrootsSimplexOfficialEncryptedHeader, RadrootsSimplexOfficialEncryptedMessage, + RadrootsSimplexOfficialMsgHeader, RadrootsSimplexOfficialX3dhInit, + decode_official_encrypted_header, decode_official_encrypted_message, + decode_official_msg_header, derive_official_x448_shared_secret, + encode_official_encrypted_header, encode_official_encrypted_message, + encode_official_msg_header, generate_official_x448_keypair, official_aes_gcm_decrypt_padded, + official_aes_gcm_encrypt_padded, official_chain_kdf, official_ratchet_header_len, + official_root_kdf, }; use alloc::vec::Vec; use hkdf::Hkdf; @@ -21,6 +22,7 @@ use sha2::Sha512; const RADROOTS_SIMPLEX_AGENT_RATCHET_INFO: &[u8] = b"SimpleXAgentRatchetMessage"; const RADROOTS_SIMPLEX_AGENT_RATCHET_OUTPUT_LENGTH: usize = RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH + RADROOTS_SIMPLEX_SMP_NONCE_LENGTH; +const RADROOTS_SIMPLEX_OFFICIAL_MAX_SKIPPED_MESSAGES: u32 = 512; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsSimplexSmpRatchetRole { @@ -52,6 +54,14 @@ impl RadrootsSimplexSmpRatchetHeader { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpSkippedMessageKey { + pub header_key: Vec<u8>, + pub message_number: u32, + pub message_key: Vec<u8>, + pub message_iv: [u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH], +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexSmpRatchetState { pub role: RadrootsSimplexSmpRatchetRole, pub root_epoch: u64, @@ -74,6 +84,7 @@ pub struct RadrootsSimplexSmpRatchetState { pub official_receiving_header_key: Option<Vec<u8>>, pub official_next_sending_header_key: Option<Vec<u8>>, pub official_next_receiving_header_key: Option<Vec<u8>>, + pub official_skipped_message_keys: Vec<RadrootsSimplexSmpSkippedMessageKey>, } impl RadrootsSimplexSmpRatchetState { @@ -110,6 +121,7 @@ impl RadrootsSimplexSmpRatchetState { official_receiving_header_key: None, official_next_sending_header_key: None, official_next_receiving_header_key: None, + official_skipped_message_keys: Vec::new(), }) } @@ -146,6 +158,7 @@ impl RadrootsSimplexSmpRatchetState { official_receiving_header_key: None, official_next_sending_header_key: None, official_next_receiving_header_key: None, + official_skipped_message_keys: Vec::new(), }) } @@ -398,6 +411,11 @@ impl RadrootsSimplexSmpRatchetState { let ratchet_ad = self.official_associated_data.clone().ok_or( RadrootsSimplexSmpCryptoError::MissingRatchetKey("official_associated_data"), )?; + if let Some(plaintext) = + self.decrypt_official_skipped_payload(&header, &message, &ratchet_ad)? + { + return Ok(plaintext); + } let (ratchet_step, ratchet_header) = self.decrypt_official_header(&header, &ratchet_ad)?; if ratchet_header.message_number < self.receiving_chain_length { return Err(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { @@ -406,8 +424,12 @@ impl RadrootsSimplexSmpRatchetState { }); } if ratchet_step == OfficialRatchetStep::Advance { + self.skip_official_receiving_messages_until( + ratchet_header.previous_sending_chain_length, + )?; self.advance_official_receiving_ratchet(&ratchet_header)?; } + self.skip_official_receiving_messages_until(ratchet_header.message_number)?; let receiving_chain_key = self.official_receiving_chain_key.clone().ok_or( RadrootsSimplexSmpCryptoError::MissingRatchetKey("official_receiving_chain_key"), )?; @@ -427,6 +449,94 @@ impl RadrootsSimplexSmpRatchetState { Ok(plaintext) } + fn decrypt_official_skipped_payload( + &mut self, + header: &RadrootsSimplexOfficialEncryptedHeader, + message: &RadrootsSimplexOfficialEncryptedMessage, + ratchet_ad: &[u8], + ) -> Result<Option<Vec<u8>>, RadrootsSimplexSmpCryptoError> { + for skipped in self.official_skipped_message_keys.clone() { + let Ok(ratchet_header) = + decrypt_official_header_with_key(header, &skipped.header_key, ratchet_ad) + else { + continue; + }; + if ratchet_header.message_number != skipped.message_number { + return Err(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: ratchet_header.message_number, + current: self.receiving_chain_length, + }); + } + let position = self + .official_skipped_message_keys + .iter() + .position(|entry| { + entry.header_key == skipped.header_key + && entry.message_number == skipped.message_number + }) + .ok_or(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: ratchet_header.message_number, + current: self.receiving_chain_length, + })?; + let skipped = self.official_skipped_message_keys.remove(position); + let message_ad = + official_message_associated_data(ratchet_ad, &message.encrypted_header); + let plaintext = official_aes_gcm_decrypt_padded( + &skipped.message_key, + &skipped.message_iv, + &RadrootsSimplexOfficialAesGcmPayload { + auth_tag: message.auth_tag.clone(), + ciphertext: message.body.clone(), + }, + &message_ad, + )?; + return Ok(Some(plaintext)); + } + Ok(None) + } + + fn skip_official_receiving_messages_until( + &mut self, + until_message_number: u32, + ) -> Result<(), RadrootsSimplexSmpCryptoError> { + if self.receiving_chain_length > until_message_number { + return Err(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: until_message_number, + current: self.receiving_chain_length, + }); + } + let skipped = until_message_number.saturating_sub(self.receiving_chain_length); + if skipped > RADROOTS_SIMPLEX_OFFICIAL_MAX_SKIPPED_MESSAGES { + return Err(RadrootsSimplexSmpCryptoError::RatchetTooManySkipped { + skipped, + max: RADROOTS_SIMPLEX_OFFICIAL_MAX_SKIPPED_MESSAGES, + }); + } + if skipped == 0 { + return Ok(()); + } + let mut receiving_chain_key = self.official_receiving_chain_key.clone().ok_or( + RadrootsSimplexSmpCryptoError::MissingRatchetKey("official_receiving_chain_key"), + )?; + let receiving_header_key = self.official_receiving_header_key.clone().ok_or( + RadrootsSimplexSmpCryptoError::MissingRatchetKey("official_receiving_header_key"), + )?; + while self.receiving_chain_length < until_message_number { + let chain = official_chain_kdf(&receiving_chain_key)?; + self.official_skipped_message_keys + .push(RadrootsSimplexSmpSkippedMessageKey { + header_key: receiving_header_key.clone(), + message_number: self.receiving_chain_length, + message_key: chain.message_key, + message_iv: chain.message_iv, + }); + receiving_chain_key = chain.chain_key; + self.receiving_chain_length = self.receiving_chain_length.saturating_add(1); + } + self.official_receiving_chain_key = Some(receiving_chain_key); + Ok(()) + } + fn decrypt_official_header( &self, header: &RadrootsSimplexOfficialEncryptedHeader, @@ -870,6 +980,81 @@ mod tests { } #[test] + fn decrypts_official_skipped_messages_once() { + let (mut sender, mut receiver) = official_sender_receiver_ratchets(); + let shared_secret = [12_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let first = sender + .encrypt_official_payload(&shared_secret, b"first", 96) + .unwrap(); + let second = sender + .encrypt_official_payload(&shared_secret, b"second", 96) + .unwrap(); + let third = sender + .encrypt_official_payload(&shared_secret, b"third", 96) + .unwrap(); + + assert_eq!( + receiver + .decrypt_official_payload(&shared_secret, &third) + .unwrap(), + b"third" + ); + assert_eq!(receiver.receiving_chain_length, 3); + assert_eq!(receiver.official_skipped_message_keys.len(), 2); + assert_eq!( + receiver + .decrypt_official_payload(&shared_secret, &first) + .unwrap(), + b"first" + ); + assert_eq!( + receiver + .decrypt_official_payload(&shared_secret, &second) + .unwrap(), + b"second" + ); + assert!(receiver.official_skipped_message_keys.is_empty()); + + let replay = receiver + .decrypt_official_payload(&shared_secret, &first) + .unwrap_err(); + assert!(matches!( + replay, + RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: 0, + current: 3 + } + )); + } + + #[test] + fn rejects_too_many_official_skipped_messages() { + let (mut sender, mut receiver) = official_sender_receiver_ratchets(); + let shared_secret = [13_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let mut encrypted = Vec::new(); + for index in 0..=RADROOTS_SIMPLEX_OFFICIAL_MAX_SKIPPED_MESSAGES + 1 { + encrypted = sender + .encrypt_official_payload(&shared_secret, &index.to_be_bytes(), 96) + .unwrap(); + } + + let error = receiver + .decrypt_official_payload(&shared_secret, &encrypted) + .unwrap_err(); + assert_eq!( + error, + RadrootsSimplexSmpCryptoError::RatchetTooManySkipped { + skipped: RADROOTS_SIMPLEX_OFFICIAL_MAX_SKIPPED_MESSAGES + 1, + max: RADROOTS_SIMPLEX_OFFICIAL_MAX_SKIPPED_MESSAGES + } + ); + assert_eq!( + error.to_string(), + "SMP ratchet skipped 513 messages, exceeding maximum 512" + ); + } + + #[test] fn rejects_tampered_official_payload_body() { let (mut sender, mut receiver) = official_sender_receiver_ratchets(); let shared_secret = [12_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; @@ -887,4 +1072,53 @@ mod tests { RadrootsSimplexSmpCryptoError::AesGcmAuthenticationFailed ); } + + #[test] + fn decrypts_official_payloads_received_out_of_order() { + let (mut sender, mut receiver) = official_sender_receiver_ratchets(); + let shared_secret = [13_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let encrypted_0 = sender + .encrypt_official_payload(&shared_secret, b"first official body", 96) + .unwrap(); + let encrypted_1 = sender + .encrypt_official_payload(&shared_secret, b"second official body", 96) + .unwrap(); + + let plaintext_1 = receiver + .decrypt_official_payload(&shared_secret, &encrypted_1) + .unwrap(); + assert_eq!(plaintext_1, b"second official body"); + assert_eq!(receiver.receiving_chain_length, 2); + assert_eq!(receiver.official_skipped_message_keys.len(), 1); + + let plaintext_0 = receiver + .decrypt_official_payload(&shared_secret, &encrypted_0) + .unwrap(); + assert_eq!(plaintext_0, b"first official body"); + assert!(receiver.official_skipped_message_keys.is_empty()); + assert_eq!(receiver.receiving_chain_length, 2); + } + + #[test] + fn rejects_too_many_official_skipped_payloads() { + let (mut sender, mut receiver) = official_sender_receiver_ratchets(); + let shared_secret = [14_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let mut encrypted = Vec::new(); + for index in 0..514 { + encrypted = sender + .encrypt_official_payload(&shared_secret, &[index as u8], 96) + .unwrap(); + } + + let error = receiver + .decrypt_official_payload(&shared_secret, &encrypted) + .unwrap_err(); + assert_eq!( + error, + RadrootsSimplexSmpCryptoError::RatchetTooManySkipped { + skipped: 513, + max: 512 + } + ); + } }