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:
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
+ }
+ );
+ }
}