lib

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

commit 12d80dc273f7007be58554e8dc757557ccddedcc
parent dbc4d19bf582b7715eb667203bb3f72b0f7e6fff
Author: triesap <tyson@radroots.org>
Date:   Mon, 22 Jun 2026 23:21:57 +0000

simplex: emit official opaque payloads

- add ratchet helpers that encrypt and decrypt opaque official message bytes
- authenticate encrypted header and body payloads with AES-GCM associated data
- switch runtime sends and receives to the official opaque payload branch
- keep focused runtime and crypto roundtrip coverage green

Diffstat:
Mcrates/simplex_agent_runtime/src/runtime.rs | 39+++++++++++++++++++++++++--------------
Mcrates/simplex_smp_crypto/src/ratchet.rs | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 326 insertions(+), 15 deletions(-)

diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -1561,7 +1561,7 @@ impl RadrootsSimplexAgentRuntime { )) })?; let padded_len = self.agent_payload_padded_len(connection_id, payload_kind)?; - let (ratchet_header, ciphertext) = self + let official_message = self .store .connection_mut(connection_id)? .ratchet_state @@ -1571,12 +1571,12 @@ impl RadrootsSimplexAgentRuntime { "SimpleX connection `{connection_id}` has no ratchet state" )) })? - .encrypt_payload(&shared_secret, &plaintext, padded_len) + .encrypt_official_payload(&shared_secret, &plaintext, padded_len) .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; Ok(RadrootsSimplexAgentEncryptedPayload { - ratchet_header: Some(ratchet_header), - official_message: None, - ciphertext, + ratchet_header: None, + official_message: Some(official_message), + ciphertext: Vec::new(), }) } @@ -1603,11 +1603,6 @@ impl RadrootsSimplexAgentRuntime { connection_id: &str, encrypted: &RadrootsSimplexAgentEncryptedPayload, ) -> Result<Vec<u8>, RadrootsSimplexAgentRuntimeError> { - if encrypted.official_message.is_some() { - return Err(RadrootsSimplexAgentRuntimeError::Runtime(format!( - "SimpleX connection `{connection_id}` received official encrypted payload before official ratchet runtime wiring is enabled" - ))); - } let shared_secret = self .store .connection(connection_id)? @@ -1618,6 +1613,20 @@ impl RadrootsSimplexAgentRuntime { "SimpleX connection `{connection_id}` has no shared secret" )) })?; + if let Some(official_message) = encrypted.official_message.as_ref() { + return self + .store + .connection_mut(connection_id)? + .ratchet_state + .as_mut() + .ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` has no ratchet state" + )) + })? + .decrypt_official_payload(&shared_secret, official_message) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string())); + } let header = encrypted.ratchet_header.as_ref().ok_or_else(|| { RadrootsSimplexAgentRuntimeError::Runtime(format!( "SimpleX connection `{connection_id}` received agent payload without ratchet header" @@ -2433,11 +2442,13 @@ mod tests { ) .unwrap(); - assert!(encrypted.ratchet_header.is_some()); - assert_ne!(encrypted.ciphertext, expected_plaintext); + assert!(encrypted.ratchet_header.is_none()); + assert!(encrypted.ciphertext.is_empty()); + let official_message = encrypted.official_message.as_ref().unwrap(); + assert_ne!(official_message, &expected_plaintext); assert_eq!( - encrypted.ciphertext.len(), - SIMPLEX_AGENT_E2E_MESSAGE_LENGTH + 16 + official_message.len(), + 2 + 124 + 16 + SIMPLEX_AGENT_E2E_MESSAGE_LENGTH ); } diff --git a/crates/simplex_smp_crypto/src/ratchet.rs b/crates/simplex_smp_crypto/src/ratchet.rs @@ -3,9 +3,18 @@ use crate::message::{ RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH, decrypt_padded, encrypt_padded, }; +use crate::official_ratchet::{ + RADROOTS_SIMPLEX_OFFICIAL_AES_KEY_LENGTH, RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + RadrootsSimplexOfficialAesGcmPayload, RadrootsSimplexOfficialChainKdfOutput, + RadrootsSimplexOfficialEncryptedHeader, RadrootsSimplexOfficialEncryptedMessage, + decode_official_encrypted_header, decode_official_encrypted_message, + encode_official_encrypted_header, encode_official_encrypted_message, + official_aes_gcm_decrypt_padded, official_aes_gcm_encrypt_padded, official_chain_kdf, + official_ratchet_header_len, +}; use alloc::vec::Vec; use hkdf::Hkdf; -use sha2::Sha512; +use sha2::{Digest, Sha256, Sha512}; const RADROOTS_SIMPLEX_AGENT_RATCHET_INFO: &[u8] = b"SimpleXAgentRatchetMessage"; const RADROOTS_SIMPLEX_AGENT_RATCHET_OUTPUT_LENGTH: usize = @@ -238,6 +247,105 @@ impl RadrootsSimplexSmpRatchetState { self.apply_inbound_header(header, None)?; Ok(plaintext) } + + pub fn encrypt_official_payload( + &mut self, + shared_secret: &[u8], + plaintext: &[u8], + padded_len: usize, + ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let message_number = self.sending_chain_length; + let header = self.next_outbound_header()?; + let header_plaintext = ratchet_header_associated_data(&header)?; + let official = derive_official_payload_keys( + shared_secret, + self.current_pq_shared_secret.as_deref(), + self.root_epoch, + message_number, + )?; + let ratchet_ad = official_ratchet_associated_data(shared_secret, self.root_epoch); + let header_payload = official_aes_gcm_encrypt_padded( + &official.header_key, + &official.chain.header_iv, + &header_plaintext, + official_ratchet_header_len( + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + self.pq_enabled(), + )?, + &ratchet_ad, + )?; + let encrypted_header = encode_official_encrypted_header(&official_encrypted_header( + official.chain.header_iv, + header_payload, + )?)?; + let message_ad = official_message_associated_data(&ratchet_ad, &encrypted_header); + let message_payload = official_aes_gcm_encrypt_padded( + &official.chain.message_key, + &official.chain.message_iv, + plaintext, + padded_len, + &message_ad, + )?; + encode_official_encrypted_message( + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + &RadrootsSimplexOfficialEncryptedMessage { + encrypted_header, + auth_tag: message_payload.auth_tag, + body: message_payload.ciphertext, + }, + ) + } + + pub fn decrypt_official_payload( + &mut self, + shared_secret: &[u8], + encrypted_message: &[u8], + ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let message_number = self.receiving_chain_length; + let official = derive_official_payload_keys( + shared_secret, + self.current_pq_shared_secret.as_deref(), + self.root_epoch, + message_number, + )?; + let message = decode_official_encrypted_message(encrypted_message)?; + let header = decode_official_encrypted_header(&message.encrypted_header)?; + let ratchet_ad = official_ratchet_associated_data(shared_secret, self.root_epoch); + let header_plaintext = official_aes_gcm_decrypt_padded( + &official.header_key, + &header.iv, + &RadrootsSimplexOfficialAesGcmPayload { + auth_tag: header.auth_tag, + ciphertext: header.body, + }, + &ratchet_ad, + )?; + let ratchet_header = decode_ratchet_header_associated_data(&header_plaintext)?; + if ratchet_header.message_number < self.receiving_chain_length { + return Err(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: ratchet_header.message_number, + current: self.receiving_chain_length, + }); + } + let message_ad = official_message_associated_data(&ratchet_ad, &message.encrypted_header); + let plaintext = official_aes_gcm_decrypt_padded( + &official.chain.message_key, + &official.chain.message_iv, + &RadrootsSimplexOfficialAesGcmPayload { + auth_tag: message.auth_tag, + ciphertext: message.body, + }, + &message_ad, + )?; + self.apply_inbound_header(&ratchet_header, None)?; + Ok(plaintext) + } + + 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() + } } fn validate_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { @@ -277,6 +385,63 @@ fn derive_ratchet_message_key( )) } +struct OfficialPayloadKeys { + header_key: Vec<u8>, + chain: RadrootsSimplexOfficialChainKdfOutput, +} + +fn derive_official_payload_keys( + shared_secret: &[u8], + pq_shared_secret: Option<&[u8]>, + root_epoch: u64, + message_number: u32, +) -> Result<OfficialPayloadKeys, RadrootsSimplexSmpCryptoError> { + let mut seed_input = + Vec::with_capacity(shared_secret.len() + pq_shared_secret.map_or(0, <[u8]>::len) + 12); + seed_input.extend_from_slice(shared_secret); + if let Some(secret) = pq_shared_secret { + seed_input.extend_from_slice(secret); + } + seed_input.extend_from_slice(&root_epoch.to_be_bytes()); + seed_input.extend_from_slice(&message_number.to_be_bytes()); + let chain_seed = Sha256::digest(&seed_input); + let chain = official_chain_kdf(&chain_seed)?; + let mut header_input = Vec::with_capacity(chain.chain_key.len() + shared_secret.len() + 12); + header_input.extend_from_slice(&chain.chain_key); + header_input.extend_from_slice(shared_secret); + header_input.extend_from_slice(&root_epoch.to_be_bytes()); + header_input.extend_from_slice(&message_number.to_be_bytes()); + let header_key = + Sha256::digest(&header_input)[..RADROOTS_SIMPLEX_OFFICIAL_AES_KEY_LENGTH].to_vec(); + Ok(OfficialPayloadKeys { header_key, chain }) +} + +fn official_encrypted_header( + iv: [u8; crate::official_ratchet::RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH], + payload: RadrootsSimplexOfficialAesGcmPayload, +) -> Result<RadrootsSimplexOfficialEncryptedHeader, RadrootsSimplexSmpCryptoError> { + Ok(RadrootsSimplexOfficialEncryptedHeader { + version: RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + iv, + auth_tag: payload.auth_tag, + body: payload.ciphertext, + }) +} + +fn official_ratchet_associated_data(shared_secret: &[u8], root_epoch: u64) -> Vec<u8> { + let mut associated_data = Vec::with_capacity(shared_secret.len() + 8); + associated_data.extend_from_slice(shared_secret); + associated_data.extend_from_slice(&root_epoch.to_be_bytes()); + associated_data +} + +fn official_message_associated_data(ratchet_ad: &[u8], encrypted_header: &[u8]) -> Vec<u8> { + let mut associated_data = Vec::with_capacity(ratchet_ad.len() + encrypted_header.len()); + associated_data.extend_from_slice(ratchet_ad); + associated_data.extend_from_slice(encrypted_header); + associated_data +} + fn ratchet_header_associated_data( header: &RadrootsSimplexSmpRatchetHeader, ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { @@ -289,6 +454,22 @@ fn ratchet_header_associated_data( Ok(buffer) } +fn decode_ratchet_header_associated_data( + bytes: &[u8], +) -> Result<RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpCryptoError> { + let mut cursor = RatchetHeaderCursor::new(bytes); + let header = RadrootsSimplexSmpRatchetHeader { + previous_sending_chain_length: cursor.read_u32()?, + message_number: cursor.read_u32()?, + dh_public_key: cursor.read_large_bytes()?, + pq_public_key: cursor.read_maybe_large_bytes()?, + pq_ciphertext: cursor.read_maybe_large_bytes()?, + }; + cursor.finish()?; + header.validate()?; + Ok(header) +} + fn push_maybe_large_bytes( buffer: &mut Vec<u8>, value: Option<&[u8]>, @@ -320,6 +501,66 @@ fn push_large_bytes( Ok(()) } +struct RatchetHeaderCursor<'a> { + bytes: &'a [u8], + position: usize, +} + +impl<'a> RatchetHeaderCursor<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, position: 0 } + } + + fn finish(&self) -> Result<(), RadrootsSimplexSmpCryptoError> { + if self.position == self.bytes.len() { + Ok(()) + } else { + Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( + self.bytes.len() - self.position, + )) + } + } + + fn read_u32(&mut self) -> Result<u32, RadrootsSimplexSmpCryptoError> { + let bytes = self.read_slice(4)?; + Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_byte(&mut self) -> Result<u8, RadrootsSimplexSmpCryptoError> { + let Some(value) = self.bytes.get(self.position) else { + return Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(0)); + }; + self.position += 1; + Ok(*value) + } + + fn read_large_bytes(&mut self) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let bytes = self.read_slice(2)?; + let length = u16::from_be_bytes([bytes[0], bytes[1]]) as usize; + Ok(self.read_slice(length)?.to_vec()) + } + + fn read_maybe_large_bytes(&mut self) -> Result<Option<Vec<u8>>, RadrootsSimplexSmpCryptoError> { + match self.read_byte()? { + 0 => Ok(None), + 1 => self.read_large_bytes().map(Some), + value => Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( + value as usize, + )), + } + } + + fn read_slice(&mut self, len: usize) -> Result<&'a [u8], RadrootsSimplexSmpCryptoError> { + let Some(bytes) = self.bytes.get(self.position..self.position + len) else { + return Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength( + self.bytes.len().saturating_sub(self.position), + )); + }; + self.position += len; + Ok(bytes) + } +} + #[cfg(test)] mod tests { use super::*; @@ -478,4 +719,63 @@ mod tests { assert_eq!(header.pq_ciphertext.as_ref().unwrap().len(), 1039); assert!(ratchet_header_associated_data(&header).unwrap().len() > 2200); } + + #[test] + fn encrypts_official_payload_as_opaque_message() { + let mut sender = RadrootsSimplexSmpRatchetState::initiator( + b"alice-dh".to_vec(), + b"bob-dh".to_vec(), + None, + ) + .unwrap(); + let mut receiver = RadrootsSimplexSmpRatchetState::responder( + b"bob-dh".to_vec(), + b"alice-dh".to_vec(), + None, + ) + .unwrap(); + let shared_secret = [11_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + + let encrypted = sender + .encrypt_official_payload(&shared_secret, b"official agent body", 96) + .unwrap(); + assert_ne!(encrypted, b"official agent body"); + assert_eq!(encrypted.len(), 2 + 124 + 16 + 96); + + let plaintext = receiver + .decrypt_official_payload(&shared_secret, &encrypted) + .unwrap(); + assert_eq!(plaintext, b"official agent body"); + assert_eq!(receiver.receiving_chain_length, 1); + } + + #[test] + fn rejects_tampered_official_payload_body() { + let mut sender = RadrootsSimplexSmpRatchetState::initiator( + b"alice-dh".to_vec(), + b"bob-dh".to_vec(), + None, + ) + .unwrap(); + let mut receiver = RadrootsSimplexSmpRatchetState::responder( + b"bob-dh".to_vec(), + b"alice-dh".to_vec(), + None, + ) + .unwrap(); + let shared_secret = [12_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let mut encrypted = sender + .encrypt_official_payload(&shared_secret, b"official agent body", 96) + .unwrap(); + let last = encrypted.len() - 1; + encrypted[last] ^= 1; + + let error = receiver + .decrypt_official_payload(&shared_secret, &encrypted) + .unwrap_err(); + assert_eq!( + error, + RadrootsSimplexSmpCryptoError::AesGcmAuthenticationFailed + ); + } }