lib

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

commit df3a8b23866958784b0f54f93da8e93f01abac78
parent 0812e5a463997eebcf414404f0c52414a1ba20a6
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 01:02:44 +0000

simplex: advance recurring pq ratchet steps

- persist local PQ private state with ratchets
- decapsulate accepted KEM headers during receive advance
- stage fresh accepted KEM material for reply ratchets
- cover two-direction official PQ ratchet progression

Diffstat:
Mcrates/simplex_agent_runtime/src/runtime.rs | 30++++++++++++++++++------------
Mcrates/simplex_agent_store/src/store.rs | 9+++++++++
Mcrates/simplex_smp_crypto/src/ratchet.rs | 205++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 220 insertions(+), 24 deletions(-)

diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -216,6 +216,7 @@ impl RadrootsSimplexAgentRuntime { .ok(); if let Some(ratchet_state) = ratchet_state.as_mut() { ratchet_state.current_pq_public_key = Some(pq_keypair.public_key.clone()); + ratchet_state.local_pq_private_key = Some(pq_keypair.private_key.clone()); } let connection = self.store.create_connection( if contact_address { @@ -355,6 +356,8 @@ impl RadrootsSimplexAgentRuntime { ratchet_state.current_pq_public_key = sender_init.sender_params.pq_public_key.clone(); ratchet_state.pending_outbound_pq_ciphertext = sender_init.sender_params.pq_ciphertext.clone(); + ratchet_state.local_pq_private_key = + Some(sender_init.local_pq_keypair.private_key.clone()); Some(sender_init.local_pq_keypair) } else { let sender_init = official_x3dh_sender_init( @@ -1693,7 +1696,7 @@ impl RadrootsSimplexAgentRuntime { let local_key_1 = official_x3dh_keypair_from_agent(local_key_1); let local_key_2 = official_x3dh_keypair_from_agent(local_key_2); let receiver_init = if params.pq_public_key.is_some() || params.pq_ciphertext.is_some() { - let local_pq_keypair = local_pq_keypair.ok_or_else(|| { + let local_pq_keypair = local_pq_keypair.as_ref().ok_or_else(|| { RadrootsSimplexAgentRuntimeError::Runtime(format!( "SimpleX connection `{connection_id}` missing local PQ keypair" )) @@ -1701,7 +1704,7 @@ impl RadrootsSimplexAgentRuntime { official_x3dh_receiver_init_accepting_pq( &local_key_1, &local_key_2, - &official_pq_keypair_from_agent(local_pq_keypair), + &official_pq_keypair_from_agent(local_pq_keypair.clone()), params, ) .map(|init| init.init) @@ -1710,15 +1713,17 @@ impl RadrootsSimplexAgentRuntime { official_x3dh_receiver_init(&local_key_1, &local_key_2, params) .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))? }; - self.store - .connection_mut(connection_id)? - .ratchet_state - .as_mut() - .ok_or_else(|| { - RadrootsSimplexAgentRuntimeError::Runtime(format!( - "SimpleX connection `{connection_id}` has no ratchet state" - )) - })? + let connection = self.store.connection_mut(connection_id)?; + let ratchet_state = connection.ratchet_state.as_mut().ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` has no ratchet state" + )) + })?; + if let Some(local_pq_keypair) = local_pq_keypair { + ratchet_state.current_pq_public_key = Some(local_pq_keypair.public_key); + ratchet_state.local_pq_private_key = Some(local_pq_keypair.private_key); + } + ratchet_state .initialize_official_receiver(local_key_2.private_key, receiver_init) .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string())) } @@ -1841,7 +1846,8 @@ impl RadrootsSimplexAgentRuntime { })?; let pq_enabled = ratchet.current_pq_public_key.is_some() || ratchet.remote_pq_public_key.is_some() - || ratchet.current_pq_shared_secret.is_some(); + || ratchet.current_pq_shared_secret.is_some() + || ratchet.local_pq_private_key.is_some(); Ok(match (payload_kind, pq_enabled) { (SimplexAgentPayloadKind::ConnectionInfo, true) => { SIMPLEX_AGENT_E2E_CONN_INFO_PQ_LENGTH diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs @@ -241,6 +241,7 @@ struct RadrootsSimplexAgentRatchetStateSnapshot { pending_outbound_pq_ciphertext: Option<Vec<u8>>, pending_inbound_pq_ciphertext: Option<Vec<u8>>, current_pq_shared_secret: Option<Vec<u8>>, + local_pq_private_key: Option<Vec<u8>>, local_dh_private_key: Option<Vec<u8>>, official_associated_data: Option<Vec<u8>>, official_root_key: Option<Vec<u8>>, @@ -1067,6 +1068,7 @@ fn ratchet_state_to_snapshot( pending_outbound_pq_ciphertext: state.pending_outbound_pq_ciphertext, pending_inbound_pq_ciphertext: state.pending_inbound_pq_ciphertext, current_pq_shared_secret: state.current_pq_shared_secret, + local_pq_private_key: state.local_pq_private_key, local_dh_private_key: state.local_dh_private_key, official_associated_data: state.official_associated_data, official_root_key: state.official_root_key, @@ -1124,6 +1126,7 @@ fn ratchet_state_from_snapshot( state.pending_outbound_pq_ciphertext = snapshot.pending_outbound_pq_ciphertext; state.pending_inbound_pq_ciphertext = snapshot.pending_inbound_pq_ciphertext; state.current_pq_shared_secret = snapshot.current_pq_shared_secret; + state.local_pq_private_key = snapshot.local_pq_private_key; state.local_dh_private_key = snapshot.local_dh_private_key; state.official_associated_data = snapshot.official_associated_data; state.official_root_key = snapshot.official_root_key; @@ -1578,6 +1581,8 @@ mod tests { let mut ratchet = RadrootsSimplexSmpRatchetState::initiator(vec![1_u8; 56], vec![2_u8; 56], None) .unwrap(); + ratchet.current_pq_public_key = Some(b"ratchet-pq-public".to_vec()); + ratchet.local_pq_private_key = Some(b"ratchet-pq-private".to_vec()); ratchet.local_dh_private_key = Some(b"official-private".to_vec()); ratchet.official_associated_data = Some(b"official-ad".to_vec()); ratchet.official_root_key = Some(b"official-root".to_vec()); @@ -1646,6 +1651,10 @@ mod tests { }] ); assert_eq!( + loaded_ratchet.local_pq_private_key.as_deref(), + Some(&b"ratchet-pq-private"[..]) + ); + assert_eq!( loaded_connection .local_x3dh_key_1 .as_ref() diff --git a/crates/simplex_smp_crypto/src/ratchet.rs b/crates/simplex_smp_crypto/src/ratchet.rs @@ -5,13 +5,17 @@ use crate::message::{ }; use crate::official_ratchet::{ RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH, RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PRIVATE_KEY_LENGTH, + RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PUBLIC_KEY_LENGTH, RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH, RadrootsSimplexOfficialAesGcmPayload, RadrootsSimplexOfficialEncryptedHeader, RadrootsSimplexOfficialEncryptedMessage, - RadrootsSimplexOfficialMsgHeader, RadrootsSimplexOfficialX3dhInit, + RadrootsSimplexOfficialMsgHeader, RadrootsSimplexOfficialSntrup761Keypair, + RadrootsSimplexOfficialX3dhInit, decapsulate_official_sntrup761, decode_official_encrypted_header, decode_official_encrypted_message, - decode_official_msg_header, derive_official_x448_shared_secret, + decode_official_msg_header, derive_official_x448_shared_secret, encapsulate_official_sntrup761, encode_official_encrypted_header, encode_official_encrypted_message, - encode_official_msg_header, generate_official_x448_keypair, official_aes_gcm_decrypt_padded, + encode_official_msg_header, generate_official_sntrup761_keypair, + generate_official_x448_keypair, official_aes_gcm_decrypt_padded, official_aes_gcm_encrypt_padded, official_chain_kdf, official_ratchet_header_len, official_root_kdf, }; @@ -75,6 +79,7 @@ pub struct RadrootsSimplexSmpRatchetState { pub pending_outbound_pq_ciphertext: Option<Vec<u8>>, pub pending_inbound_pq_ciphertext: Option<Vec<u8>>, pub current_pq_shared_secret: Option<Vec<u8>>, + pub local_pq_private_key: Option<Vec<u8>>, pub local_dh_private_key: Option<Vec<u8>>, pub official_associated_data: Option<Vec<u8>>, pub official_root_key: Option<Vec<u8>>, @@ -112,6 +117,7 @@ impl RadrootsSimplexSmpRatchetState { pending_outbound_pq_ciphertext: None, pending_inbound_pq_ciphertext: None, current_pq_shared_secret: None, + local_pq_private_key: None, local_dh_private_key: None, official_associated_data: None, official_root_key: None, @@ -149,6 +155,7 @@ impl RadrootsSimplexSmpRatchetState { pending_outbound_pq_ciphertext: None, pending_inbound_pq_ciphertext: None, current_pq_shared_secret: None, + local_pq_private_key: None, local_dh_private_key: None, official_associated_data: None, official_root_key: None, @@ -567,6 +574,7 @@ impl RadrootsSimplexSmpRatchetState { &mut self, header: &RadrootsSimplexSmpRatchetHeader, ) -> Result<(), RadrootsSimplexSmpCryptoError> { + let pq_step = self.official_pq_receiving_step(header)?; let local_private_key = self.local_dh_private_key.clone().ok_or( RadrootsSimplexSmpCryptoError::MissingRatchetKey("local_dh_private_key"), )?; @@ -575,25 +583,35 @@ impl RadrootsSimplexSmpRatchetState { )?; let receiving_dh = derive_official_x448_shared_secret(&local_private_key, &header.dh_public_key)?; - let receiving_pq_shared_secret = if header.pq_ciphertext.is_some() { - self.current_pq_shared_secret.as_deref() - } else { - None - }; - let receiving_root = - official_root_kdf(&root_key, &receiving_dh, receiving_pq_shared_secret)?; + let receiving_root = official_root_kdf( + &root_key, + &receiving_dh, + pq_step.receiving_shared_secret.as_deref(), + )?; let next_local_keypair = generate_official_x448_keypair()?; let sending_dh = derive_official_x448_shared_secret( &next_local_keypair.private_key, &header.dh_public_key, )?; - let sending_root = official_root_kdf(&receiving_root.root_key, &sending_dh, None)?; + let sending_root = official_root_kdf( + &receiving_root.root_key, + &sending_dh, + pq_step.sending_shared_secret.as_deref(), + )?; self.previous_sending_chain_length = self.sending_chain_length; self.sending_chain_length = 0; self.receiving_chain_length = 0; self.remote_dh_public_key = header.dh_public_key.clone(); self.remote_pq_public_key = header.pq_public_key.clone(); self.pending_inbound_pq_ciphertext = header.pq_ciphertext.clone(); + if let Some(next_pq_keypair) = pq_step.next_local_keypair { + self.current_pq_public_key = Some(next_pq_keypair.public_key); + self.local_pq_private_key = Some(next_pq_keypair.private_key); + self.pending_outbound_pq_ciphertext = pq_step.pending_outbound_pq_ciphertext; + self.current_pq_shared_secret = pq_step.sending_shared_secret; + } else if pq_step.receiving_shared_secret.is_some() { + self.current_pq_shared_secret = pq_step.receiving_shared_secret; + } self.local_dh_public_key = next_local_keypair.public_key; self.local_dh_private_key = Some(next_local_keypair.private_key); self.official_root_key = Some(sending_root.root_key); @@ -606,13 +624,61 @@ impl RadrootsSimplexSmpRatchetState { Ok(()) } + fn official_pq_receiving_step( + &self, + header: &RadrootsSimplexSmpRatchetHeader, + ) -> Result<OfficialPqReceivingStep, RadrootsSimplexSmpCryptoError> { + let Some(remote_pq_public_key) = header.pq_public_key.as_deref() else { + return Ok(OfficialPqReceivingStep::default()); + }; + validate_official_pq_public_key(remote_pq_public_key)?; + let receiving_shared_secret = match header.pq_ciphertext.as_deref() { + Some(ciphertext) => { + let local_pq_private_key = self.local_pq_private_key.as_deref().ok_or( + RadrootsSimplexSmpCryptoError::MissingRatchetKey("local_pq_private_key"), + )?; + validate_official_pq_private_key(local_pq_private_key)?; + Some(decapsulate_official_sntrup761( + local_pq_private_key, + ciphertext, + )?) + } + None => None, + }; + if !self.pq_enabled() { + return Ok(OfficialPqReceivingStep { + receiving_shared_secret, + ..OfficialPqReceivingStep::default() + }); + } + let next_local_keypair = generate_official_sntrup761_keypair()?; + let seed = random_official_pq_seed()?; + let (pending_outbound_pq_ciphertext, sending_shared_secret) = + encapsulate_official_sntrup761(remote_pq_public_key, &seed)?; + Ok(OfficialPqReceivingStep { + receiving_shared_secret, + next_local_keypair: Some(next_local_keypair), + pending_outbound_pq_ciphertext: Some(pending_outbound_pq_ciphertext), + sending_shared_secret: Some(sending_shared_secret), + }) + } + fn pq_enabled(&self) -> bool { self.current_pq_public_key.is_some() || self.remote_pq_public_key.is_some() || self.current_pq_shared_secret.is_some() + || self.local_pq_private_key.is_some() } } +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct OfficialPqReceivingStep { + receiving_shared_secret: Option<Vec<u8>>, + next_local_keypair: Option<RadrootsSimplexOfficialSntrup761Keypair>, + pending_outbound_pq_ciphertext: Option<Vec<u8>>, + sending_shared_secret: Option<Vec<u8>>, +} + fn validate_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { if value.is_empty() { return Err(RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength(0)); @@ -620,6 +686,31 @@ fn validate_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError Ok(()) } +fn validate_official_pq_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { + if value.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PUBLIC_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPqKeyLength( + value.len(), + )); + } + Ok(()) +} + +fn validate_official_pq_private_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { + if value.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PRIVATE_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPrivateKeyLength( + value.len(), + )); + } + Ok(()) +} + +fn random_official_pq_seed() -> Result<[u8; 32], RadrootsSimplexSmpCryptoError> { + let mut seed = [0_u8; 32]; + getrandom::getrandom(&mut seed) + .map_err(|_| RadrootsSimplexSmpCryptoError::EntropyUnavailable)?; + Ok(seed) +} + fn validate_official_private_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { if value.len() != RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH { return Err(RadrootsSimplexSmpCryptoError::InvalidPrivateKeyLength( @@ -776,7 +867,9 @@ mod tests { use super::*; use crate::official_ratchet::{ RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, - RadrootsSimplexOfficialX3dhParams, official_x3dh_receiver_init, official_x3dh_sender_init, + RadrootsSimplexOfficialX3dhParams, official_sntrup761_keypair_from_seed, + official_x3dh_receiver_init, official_x3dh_receiver_init_accepting_pq, + official_x3dh_sender_init, official_x3dh_sender_init_accepting_pq, official_x448_keypair_from_seed, }; use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpVersionRange; @@ -832,6 +925,68 @@ mod tests { (sender, receiver) } + fn official_pq_sender_receiver_ratchets() -> ( + RadrootsSimplexSmpRatchetState, + RadrootsSimplexSmpRatchetState, + ) { + let receiver_key_1 = official_x448_keypair_from_seed(b"rr-synth-pq-ratchet-rcv-1"); + let receiver_key_2 = official_x448_keypair_from_seed(b"rr-synth-pq-ratchet-rcv-2"); + let receiver_pq_keypair = official_sntrup761_keypair_from_seed(b"rr-synth-pq-rcv-kem"); + let sender_key_1 = official_x448_keypair_from_seed(b"rr-synth-pq-ratchet-snd-1"); + let sender_key_2 = official_x448_keypair_from_seed(b"rr-synth-pq-ratchet-snd-2"); + let sender_pq_keypair = official_sntrup761_keypair_from_seed(b"rr-synth-pq-snd-kem"); + let receiver_params = RadrootsSimplexOfficialX3dhParams { + version_range: RadrootsSimplexSmpVersionRange::new( + RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + ) + .unwrap(), + key_1: receiver_key_1.public_key.clone(), + key_2: receiver_key_2.public_key.clone(), + pq_public_key: Some(receiver_pq_keypair.public_key.clone()), + pq_ciphertext: None, + }; + let sender_init = official_x3dh_sender_init_accepting_pq( + &sender_key_1, + &sender_key_2, + sender_pq_keypair, + &receiver_params, + b"rr-synth-pq-x3dh-accept", + ) + .unwrap(); + let receiver_init = official_x3dh_receiver_init_accepting_pq( + &receiver_key_1, + &receiver_key_2, + &receiver_pq_keypair, + &sender_init.sender_params, + ) + .unwrap(); + let mut sender = RadrootsSimplexSmpRatchetState::responder( + sender_key_2.public_key.clone(), + receiver_key_2.public_key.clone(), + sender_init.sender_params.pq_public_key.clone(), + ) + .unwrap(); + sender + .initialize_official_sender(sender_key_2.private_key, sender_init.init) + .unwrap(); + sender.current_pq_public_key = sender_init.sender_params.pq_public_key.clone(); + sender.pending_outbound_pq_ciphertext = sender_init.sender_params.pq_ciphertext.clone(); + sender.local_pq_private_key = Some(sender_init.local_pq_keypair.private_key); + let mut receiver = RadrootsSimplexSmpRatchetState::initiator( + receiver_key_2.public_key.clone(), + receiver_key_1.public_key.clone(), + None, + ) + .unwrap(); + receiver.current_pq_public_key = Some(receiver_pq_keypair.public_key); + receiver.local_pq_private_key = Some(receiver_pq_keypair.private_key); + receiver + .initialize_official_receiver(receiver_key_2.private_key, receiver_init.init) + .unwrap(); + (sender, receiver) + } + #[test] fn stages_outbound_pq_state_and_emits_header() { let mut state = RadrootsSimplexSmpRatchetState::responder( @@ -994,6 +1149,32 @@ mod tests { } #[test] + fn advances_official_pq_ratchet_in_both_directions() { + let (mut sender, mut receiver) = official_pq_sender_receiver_ratchets(); + let shared_secret = [21_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + + let encrypted = sender + .encrypt_official_payload(&shared_secret, b"pq first", 96) + .unwrap(); + let plaintext = receiver + .decrypt_official_payload(&shared_secret, &encrypted) + .unwrap(); + assert_eq!(plaintext, b"pq first"); + assert!(receiver.pending_outbound_pq_ciphertext.is_some()); + assert!(receiver.local_pq_private_key.is_some()); + + let reply = receiver + .encrypt_official_payload(&shared_secret, b"pq reply", 96) + .unwrap(); + let reply_plaintext = sender + .decrypt_official_payload(&shared_secret, &reply) + .unwrap(); + assert_eq!(reply_plaintext, b"pq reply"); + assert!(sender.pending_outbound_pq_ciphertext.is_some()); + assert!(sender.local_pq_private_key.is_some()); + } + + #[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];