lib

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

commit 1d5afb26133755df00a3cdb0cbd5d9b99cf094ac
parent 1572e683bae81bf4fcf48fda62068eeb79432d01
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 00:43:07 +0000

simplex: model official pq x3dh headers

- encode and decode official version-3 KEM header params

- add explicit SNTRUP-backed X3DH PQ init helpers

- feed accepted KEM material into sender ratchet initialization

- allow proposed-only PQ headers while rejecting ciphertext without a key

Diffstat:
Mcrates/simplex_agent_proto/src/codec.rs | 4++--
Mcrates/simplex_smp_crypto/src/lib.rs | 4+++-
Mcrates/simplex_smp_crypto/src/official_ratchet.rs | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/simplex_smp_crypto/src/ratchet.rs | 14++++++++++----
4 files changed, 345 insertions(+), 24 deletions(-)

diff --git a/crates/simplex_agent_proto/src/codec.rs b/crates/simplex_agent_proto/src/codec.rs @@ -882,8 +882,8 @@ mod tests { previous_sending_chain_length: 0, message_number: 0, dh_public_key: vec![1_u8; 56], - pq_public_key: Some(vec![2_u8; 1158]), - pq_ciphertext: None, + pq_public_key: None, + pq_ciphertext: Some(vec![3_u8; 1039]), }), official_message: None, ciphertext: b"opaque".to_vec(), diff --git a/crates/simplex_smp_crypto/src/lib.rs b/crates/simplex_smp_crypto/src/lib.rs @@ -41,6 +41,7 @@ pub mod prelude { RadrootsSimplexOfficialEncryptedMessage, RadrootsSimplexOfficialMsgHeader, RadrootsSimplexOfficialRootKdfOutput, RadrootsSimplexOfficialSntrup761Keypair, RadrootsSimplexOfficialX3dhInit, RadrootsSimplexOfficialX3dhParams, + RadrootsSimplexOfficialX3dhReceiverPqInit, RadrootsSimplexOfficialX3dhSenderPqInit, RadrootsSimplexOfficialX448Keypair, decapsulate_official_sntrup761, decode_official_encrypted_header, decode_official_encrypted_message, decode_official_msg_header, decode_official_x3dh_params_uri, @@ -53,7 +54,8 @@ pub mod prelude { official_encoded_encrypted_header_len, official_encoded_encrypted_message_len, official_full_header_len, official_ratchet_header_len, official_root_kdf, official_sntrup761_keypair_from_seed, official_x3dh_receiver_init, - official_x3dh_sender_init, official_x448_keypair_from_seed, + official_x3dh_receiver_init_accepting_pq, official_x3dh_sender_init, + official_x3dh_sender_init_accepting_pq, official_x448_keypair_from_seed, }; pub use crate::ratchet::{ RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetRole, diff --git a/crates/simplex_smp_crypto/src/official_ratchet.rs b/crates/simplex_smp_crypto/src/official_ratchet.rs @@ -87,6 +87,7 @@ pub struct RadrootsSimplexOfficialX3dhInit { pub ratchet_key: Vec<u8>, pub sending_header_key: Vec<u8>, pub receiving_next_header_key: Vec<u8>, + pub accepted_pq_shared_secret: Option<Vec<u8>>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -114,6 +115,20 @@ pub struct RadrootsSimplexOfficialChainKdfOutput { pub header_iv: [u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH], } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexOfficialX3dhSenderPqInit { + pub init: RadrootsSimplexOfficialX3dhInit, + pub sender_params: RadrootsSimplexOfficialX3dhParams, + pub local_pq_keypair: RadrootsSimplexOfficialSntrup761Keypair, + pub pq_shared_secret: Vec<u8>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexOfficialX3dhReceiverPqInit { + pub init: RadrootsSimplexOfficialX3dhInit, + pub pq_shared_secret: Vec<u8>, +} + pub fn official_ratchet_header_len( version: u16, pq_enabled: bool, @@ -349,6 +364,7 @@ pub fn official_x3dh_sender_init( derive_official_x448_shared_secret(&local_key_1.private_key, &remote_params.key_2)?, derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_2)?, ], + None, ) } @@ -371,9 +387,97 @@ pub fn official_x3dh_receiver_init( derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_1)?, derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_2)?, ], + None, ) } +pub fn official_x3dh_sender_init_accepting_pq( + local_key_1: &RadrootsSimplexOfficialX448Keypair, + local_key_2: &RadrootsSimplexOfficialX448Keypair, + local_pq_keypair: RadrootsSimplexOfficialSntrup761Keypair, + remote_params: &RadrootsSimplexOfficialX3dhParams, + encapsulation_seed: &[u8], +) -> Result<RadrootsSimplexOfficialX3dhSenderPqInit, RadrootsSimplexSmpCryptoError> { + validate_official_x3dh_keypair(local_key_1)?; + validate_official_x3dh_keypair(local_key_2)?; + validate_official_sntrup761_keypair(&local_pq_keypair)?; + validate_official_x3dh_params(remote_params)?; + let remote_pq_public_key = remote_params.pq_public_key.as_deref().ok_or( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "PQ sender init requires remote proposed KEM key".to_owned(), + ), + )?; + if remote_params.pq_ciphertext.is_some() { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "PQ sender init requires proposed KEM key without ciphertext".to_owned(), + ), + ); + } + let (pq_ciphertext, pq_shared_secret) = + encapsulate_official_sntrup761(remote_pq_public_key, encapsulation_seed)?; + let init = official_x3dh_init( + &local_key_1.public_key, + &remote_params.key_1, + &[ + derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_1)?, + derive_official_x448_shared_secret(&local_key_1.private_key, &remote_params.key_2)?, + derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_2)?, + ], + Some(&pq_shared_secret), + )?; + let sender_params = RadrootsSimplexOfficialX3dhParams { + version_range: remote_params.version_range, + key_1: local_key_1.public_key.clone(), + key_2: local_key_2.public_key.clone(), + pq_public_key: Some(local_pq_keypair.public_key.clone()), + pq_ciphertext: Some(pq_ciphertext), + }; + validate_official_x3dh_params(&sender_params)?; + Ok(RadrootsSimplexOfficialX3dhSenderPqInit { + init, + sender_params, + local_pq_keypair, + pq_shared_secret, + }) +} + +pub fn official_x3dh_receiver_init_accepting_pq( + local_key_1: &RadrootsSimplexOfficialX448Keypair, + local_key_2: &RadrootsSimplexOfficialX448Keypair, + local_pq_keypair: &RadrootsSimplexOfficialSntrup761Keypair, + remote_params: &RadrootsSimplexOfficialX3dhParams, +) -> Result<RadrootsSimplexOfficialX3dhReceiverPqInit, RadrootsSimplexSmpCryptoError> { + validate_official_x3dh_keypair(local_key_1)?; + validate_official_x3dh_keypair(local_key_2)?; + validate_official_sntrup761_keypair(local_pq_keypair)?; + validate_official_x3dh_params(remote_params)?; + let pq_ciphertext = remote_params.pq_ciphertext.as_deref().ok_or( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "PQ receiver init requires accepted KEM ciphertext".to_owned(), + ), + )?; + if remote_params.pq_public_key.is_none() { + return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader); + } + let pq_shared_secret = + decapsulate_official_sntrup761(&local_pq_keypair.private_key, pq_ciphertext)?; + let init = official_x3dh_init( + &remote_params.key_1, + &local_key_1.public_key, + &[ + derive_official_x448_shared_secret(&local_key_1.private_key, &remote_params.key_2)?, + derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_1)?, + derive_official_x448_shared_secret(&local_key_2.private_key, &remote_params.key_2)?, + ], + Some(&pq_shared_secret), + )?; + Ok(RadrootsSimplexOfficialX3dhReceiverPqInit { + init, + pq_shared_secret, + }) +} + pub fn official_sntrup761_keypair_from_seed( seed: &[u8], ) -> RadrootsSimplexOfficialSntrup761Keypair { @@ -496,15 +600,18 @@ pub fn encode_official_msg_header( ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { validate_official_version(version)?; validate_official_version(header.max_version)?; - if header.pq_public_key.is_some() || header.pq_ciphertext.is_some() { - return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader); - } let public_key = encode_official_x448_public_key_der(&header.dh_public_key)?; let mut buffer = Vec::with_capacity(2 + 1 + public_key.len() + 1 + 4 + 4); buffer.extend_from_slice(&header.max_version.to_be_bytes()); push_official_short_bytes(&mut buffer, &public_key)?; if version >= RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION { - buffer.push(b'0'); + push_official_msg_header_pq(&mut buffer, header)?; + } else if header.pq_public_key.is_some() || header.pq_ciphertext.is_some() { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "PQ header params require E2E version 3".to_owned(), + ), + ); } buffer.extend_from_slice(&header.previous_sending_chain_length.to_be_bytes()); buffer.extend_from_slice(&header.message_number.to_be_bytes()); @@ -520,25 +627,19 @@ pub fn decode_official_msg_header( let max_version = cursor.read_u16()?; validate_official_version(max_version)?; let dh_public_key = decode_official_x448_public_key_der(cursor.read_short_bytes()?)?; - if version >= RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION { - match cursor.read_byte()? { - b'0' => {} - b'1' => return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader), - value => { - return Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( - value as usize, - )); - } - } - } + let (pq_public_key, pq_ciphertext) = if version >= RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION { + read_official_msg_header_pq(&mut cursor)? + } else { + (None, None) + }; let previous_sending_chain_length = cursor.read_u32()?; let message_number = cursor.read_u32()?; cursor.finish()?; Ok(RadrootsSimplexOfficialMsgHeader { max_version, dh_public_key, - pq_public_key: None, - pq_ciphertext: None, + pq_public_key, + pq_ciphertext, previous_sending_chain_length, message_number, }) @@ -829,10 +930,27 @@ fn validate_official_x3dh_keypair( Ok(()) } +fn validate_official_sntrup761_keypair( + keypair: &RadrootsSimplexOfficialSntrup761Keypair, +) -> Result<(), RadrootsSimplexSmpCryptoError> { + if keypair.public_key.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PUBLIC_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPqKeyLength( + keypair.public_key.len(), + )); + } + if keypair.private_key.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PRIVATE_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPrivateKeyLength( + keypair.private_key.len(), + )); + } + Ok(()) +} + fn official_x3dh_init( sender_key_1: &[u8], receiver_key_1: &[u8], shared_secrets: &[Vec<u8>; 3], + pq_shared_secret: Option<&[u8]>, ) -> Result<RadrootsSimplexOfficialX3dhInit, RadrootsSimplexSmpCryptoError> { let mut associated_data = Vec::with_capacity(sender_key_1.len() + receiver_key_1.len()); associated_data.extend_from_slice(sender_key_1); @@ -848,6 +966,14 @@ fn official_x3dh_init( } input.extend_from_slice(shared_secret); } + if let Some(pq_shared_secret) = pq_shared_secret { + if pq_shared_secret.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_SHARED_SECRET_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidSharedSecretLength( + pq_shared_secret.len(), + )); + } + input.extend_from_slice(pq_shared_secret); + } let zero_salt = [0_u8; 64]; let (sending_header_key, receiving_next_header_key, ratchet_key) = official_hkdf3(&zero_salt, &input, RADROOTS_SIMPLEX_OFFICIAL_X3DH_INFO)?; @@ -856,9 +982,96 @@ fn official_x3dh_init( ratchet_key, sending_header_key, receiving_next_header_key, + accepted_pq_shared_secret: pq_shared_secret.map(<[u8]>::to_vec), }) } +fn push_official_msg_header_pq( + buffer: &mut Vec<u8>, + header: &RadrootsSimplexOfficialMsgHeader, +) -> Result<(), RadrootsSimplexSmpCryptoError> { + match ( + header.pq_public_key.as_deref(), + header.pq_ciphertext.as_deref(), + ) { + (None, None) => buffer.push(b'0'), + (Some(pq_public_key), None) => { + validate_official_pq_public_key(pq_public_key)?; + buffer.push(b'1'); + buffer.push(b'P'); + push_official_large_by_version( + buffer, + RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION, + pq_public_key, + )?; + } + (Some(pq_public_key), Some(pq_ciphertext)) => { + validate_official_pq_public_key(pq_public_key)?; + validate_official_pq_ciphertext(pq_ciphertext)?; + buffer.push(b'1'); + buffer.push(b'A'); + push_official_large_by_version( + buffer, + RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION, + pq_ciphertext, + )?; + push_official_large_by_version( + buffer, + RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION, + pq_public_key, + )?; + } + (None, Some(_)) => return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader), + } + Ok(()) +} + +fn read_official_msg_header_pq( + cursor: &mut OfficialCursor<'_>, +) -> Result<(Option<Vec<u8>>, Option<Vec<u8>>), RadrootsSimplexSmpCryptoError> { + match cursor.read_byte()? { + b'0' => Ok((None, None)), + b'1' => match cursor.read_byte()? { + b'P' => { + let pq_public_key = cursor.read_official_large()?.to_vec(); + validate_official_pq_public_key(&pq_public_key)?; + Ok((Some(pq_public_key), None)) + } + b'A' => { + let pq_ciphertext = cursor.read_official_large()?.to_vec(); + let pq_public_key = cursor.read_official_large()?.to_vec(); + validate_official_pq_ciphertext(&pq_ciphertext)?; + validate_official_pq_public_key(&pq_public_key)?; + Ok((Some(pq_public_key), Some(pq_ciphertext))) + } + value => Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( + value as usize, + )), + }, + value => Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( + value as usize, + )), + } +} + +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_ciphertext(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { + if value.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_CIPHERTEXT_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPqCiphertextLength( + value.len(), + )); + } + Ok(()) +} + fn encode_official_urlsafe_bytes(bytes: &[u8]) -> String { URL_SAFE.encode(bytes) } @@ -1115,6 +1328,56 @@ mod tests { } #[test] + fn official_pq_msg_headers_roundtrip_proposed_and_accepted_kem() { + let keypair = official_x448_keypair_from_seed(b"rr-synth-official-header-pq-x448"); + let pq_keypair = official_sntrup761_keypair_from_seed(b"rr-synth-official-header-pq-kem"); + let (pq_ciphertext, _) = + encapsulate_official_sntrup761(&pq_keypair.public_key, b"rr-synth-header-pq-ct") + .unwrap(); + let proposed = RadrootsSimplexOfficialMsgHeader { + max_version: RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + dh_public_key: keypair.public_key.clone(), + pq_public_key: Some(pq_keypair.public_key.clone()), + pq_ciphertext: None, + previous_sending_chain_length: 5, + message_number: 8, + }; + let encoded_proposed = + encode_official_msg_header(RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, &proposed) + .unwrap(); + assert_eq!(encoded_proposed.len(), 1_241); + assert_eq!( + decode_official_msg_header( + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + &encoded_proposed + ) + .unwrap(), + proposed + ); + + let accepted = RadrootsSimplexOfficialMsgHeader { + max_version: RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + dh_public_key: keypair.public_key, + pq_public_key: Some(pq_keypair.public_key), + pq_ciphertext: Some(pq_ciphertext), + previous_sending_chain_length: 9, + message_number: 10, + }; + let encoded_accepted = + encode_official_msg_header(RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, &accepted) + .unwrap(); + assert_eq!(encoded_accepted.len(), 2_282); + assert_eq!( + decode_official_msg_header( + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + &encoded_accepted + ) + .unwrap(), + accepted + ); + } + + #[test] fn official_x3dh_params_uri_roundtrips() { let keypair_1 = official_x448_keypair_from_seed(b"rr-synth-official-x3dh-1"); let keypair_2 = official_x448_keypair_from_seed(b"rr-synth-official-x3dh-2"); @@ -1204,6 +1467,56 @@ mod tests { sender_init.receiving_next_header_key.len(), RADROOTS_SIMPLEX_OFFICIAL_AES_KEY_LENGTH ); + assert!(sender_init.accepted_pq_shared_secret.is_none()); + } + + #[test] + fn official_x3dh_pq_init_matches_on_both_sides() { + let receiver_key_1 = official_x448_keypair_from_seed(b"rr-synth-x3dh-pq-rcv-1"); + let receiver_key_2 = official_x448_keypair_from_seed(b"rr-synth-x3dh-pq-rcv-2"); + let sender_key_1 = official_x448_keypair_from_seed(b"rr-synth-x3dh-pq-snd-1"); + let sender_key_2 = official_x448_keypair_from_seed(b"rr-synth-x3dh-pq-snd-2"); + let receiver_pq = official_sntrup761_keypair_from_seed(b"rr-synth-x3dh-pq-rcv-kem"); + let sender_pq = official_sntrup761_keypair_from_seed(b"rr-synth-x3dh-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.public_key.clone()), + pq_ciphertext: None, + }; + + let sender_init = official_x3dh_sender_init_accepting_pq( + &sender_key_1, + &sender_key_2, + sender_pq.clone(), + &receiver_params, + b"rr-synth-x3dh-pq-encap", + ) + .unwrap(); + let receiver_init = official_x3dh_receiver_init_accepting_pq( + &receiver_key_1, + &receiver_key_2, + &receiver_pq, + &sender_init.sender_params, + ) + .unwrap(); + + assert_eq!(sender_init.init, receiver_init.init); + assert_eq!(sender_init.pq_shared_secret, receiver_init.pq_shared_secret); + assert_eq!( + sender_init.init.accepted_pq_shared_secret.as_deref(), + Some(sender_init.pq_shared_secret.as_slice()) + ); + assert_eq!( + sender_init.sender_params.pq_public_key, + Some(sender_pq.public_key) + ); + assert!(sender_init.sender_params.pq_ciphertext.is_some()); } #[test] diff --git a/crates/simplex_smp_crypto/src/ratchet.rs b/crates/simplex_smp_crypto/src/ratchet.rs @@ -46,7 +46,7 @@ impl RadrootsSimplexSmpRatchetHeader { "dh_public_key", )); } - if self.pq_public_key.is_some() != self.pq_ciphertext.is_some() { + if self.pq_ciphertext.is_some() && self.pq_public_key.is_none() { return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader); } Ok(()) @@ -170,12 +170,17 @@ impl RadrootsSimplexSmpRatchetState { validate_official_private_key(&local_dh_private_key)?; let root_dh = derive_official_x448_shared_secret(&local_dh_private_key, &self.remote_dh_public_key)?; - let root = official_root_kdf(&init.ratchet_key, &root_dh, None)?; + let root = official_root_kdf( + &init.ratchet_key, + &root_dh, + init.accepted_pq_shared_secret.as_deref(), + )?; self.local_dh_private_key = Some(local_dh_private_key); self.official_associated_data = Some(init.associated_data); self.official_root_key = Some(root.root_key); self.official_sending_chain_key = Some(root.chain_key); self.official_receiving_chain_key = None; + self.current_pq_shared_secret = init.accepted_pq_shared_secret; self.official_sending_header_key = Some(init.sending_header_key); self.official_receiving_header_key = None; self.official_next_sending_header_key = Some(root.next_header_key); @@ -196,6 +201,7 @@ impl RadrootsSimplexSmpRatchetState { self.local_dh_private_key = Some(local_dh_private_key); self.official_associated_data = Some(init.associated_data); self.official_root_key = Some(init.ratchet_key); + self.current_pq_shared_secret = init.accepted_pq_shared_secret; self.official_sending_chain_key = None; self.official_receiving_chain_key = None; self.official_sending_header_key = None; @@ -890,8 +896,8 @@ mod tests { previous_sending_chain_length: 0, message_number: 0, dh_public_key: b"dh".to_vec(), - pq_public_key: Some(b"pq".to_vec()), - pq_ciphertext: None, + pq_public_key: None, + pq_ciphertext: Some(b"ciphertext".to_vec()), }; let error = header.validate().unwrap_err();