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:
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];