lib

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

commit 8f5707c73e23c9841b84e6f96fe578e38b286e37
parent 74df19344b78864e465a5d98e2fe6336deb02c74
Author: triesap <tyson@radroots.org>
Date:   Mon, 22 Jun 2026 22:52:37 +0000

simplex: encrypt agent ratchet payloads

- derive per-message ratchet keys from shared secret and header state
- reject malformed and tampered ratchet headers before app decode
- allow official-sized PQ header material in agent envelopes
- keep runtime sends behind encrypted agent envelope payloads

Diffstat:
Mcrates/simplex_agent_proto/src/codec.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/simplex_agent_proto/src/error.rs | 4++++
Mcrates/simplex_agent_runtime/src/runtime.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/simplex_interop_tests/src/lib.rs | 17++++-------------
Mcrates/simplex_smp_crypto/src/ratchet.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 482 insertions(+), 70 deletions(-)

diff --git a/crates/simplex_agent_proto/src/codec.rs b/crates/simplex_agent_proto/src/codec.rs @@ -418,12 +418,15 @@ fn decode_queue_address( fn encode_ratchet_header( header: &RadrootsSimplexSmpRatchetHeader, ) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + header + .validate() + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidRatchetHeader(error.to_string()))?; let mut buffer = Vec::new(); buffer.extend_from_slice(&header.previous_sending_chain_length.to_be_bytes()); buffer.extend_from_slice(&header.message_number.to_be_bytes()); push_short_bytes(&mut buffer, &header.dh_public_key)?; - push_maybe_short_bytes(&mut buffer, header.pq_public_key.as_deref())?; - push_maybe_short_bytes(&mut buffer, header.pq_ciphertext.as_deref())?; + push_maybe_large_bytes(&mut buffer, header.pq_public_key.as_deref())?; + push_maybe_large_bytes(&mut buffer, header.pq_ciphertext.as_deref())?; Ok(buffer) } @@ -435,10 +438,13 @@ fn decode_ratchet_header( previous_sending_chain_length: cursor.read_u32()?, message_number: cursor.read_u32()?, dh_public_key: cursor.read_short_bytes()?, - pq_public_key: cursor.read_maybe(decode_short_bytes)?, - pq_ciphertext: cursor.read_maybe(decode_short_bytes)?, + pq_public_key: cursor.read_maybe(decode_large_bytes)?, + pq_ciphertext: cursor.read_maybe(decode_large_bytes)?, }; cursor.finish()?; + header + .validate() + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidRatchetHeader(error.to_string()))?; Ok(header) } @@ -446,6 +452,10 @@ fn decode_short_bytes(cursor: &mut Cursor<'_>) -> Result<Vec<u8>, RadrootsSimple cursor.read_short_bytes() } +fn decode_large_bytes(cursor: &mut Cursor<'_>) -> Result<Vec<u8>, RadrootsSimplexAgentProtoError> { + cursor.read_large_bytes() +} + fn push_short_bytes( buffer: &mut Vec<u8>, value: &[u8], @@ -490,6 +500,22 @@ fn push_maybe_short_bytes( } } +fn push_maybe_large_bytes( + buffer: &mut Vec<u8>, + value: Option<&[u8]>, +) -> Result<(), RadrootsSimplexAgentProtoError> { + match value { + Some(value) => { + buffer.push(1); + push_large_bytes(buffer, value) + } + None => { + buffer.push(0); + Ok(()) + } + } +} + fn push_maybe<T>( buffer: &mut Vec<u8>, value: Option<&T>, @@ -642,8 +668,10 @@ impl<'a> Cursor<'a> { Ok(values) } - fn read_remaining(&self) -> &'a [u8] { - &self.bytes[self.position..] + fn read_remaining(&mut self) -> &'a [u8] { + let remaining = &self.bytes[self.position..]; + self.position = self.bytes.len(); + remaining } } @@ -719,4 +747,44 @@ mod tests { Some(RadrootsSimplexSmpQueueMode::Messaging) ); } + + #[test] + fn roundtrips_official_sized_pq_ratchet_header() { + let envelope = + RadrootsSimplexAgentEnvelope::Message(RadrootsSimplexAgentEncryptedPayload { + ratchet_header: Some(RadrootsSimplexSmpRatchetHeader { + previous_sending_chain_length: 4, + message_number: 9, + dh_public_key: vec![7_u8; 56], + pq_public_key: Some(vec![8_u8; 1158]), + pq_ciphertext: Some(vec![9_u8; 1039]), + }), + ciphertext: b"opaque".to_vec(), + }); + + let encoded = encode_envelope(&envelope).unwrap(); + let decoded = decode_envelope(&encoded).unwrap(); + assert_eq!(decoded, envelope); + } + + #[test] + fn rejects_incomplete_pq_ratchet_header() { + let envelope = + RadrootsSimplexAgentEnvelope::Message(RadrootsSimplexAgentEncryptedPayload { + ratchet_header: Some(RadrootsSimplexSmpRatchetHeader { + 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, + }), + ciphertext: b"opaque".to_vec(), + }); + + let error = encode_envelope(&envelope).unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexAgentProtoError::InvalidRatchetHeader(_) + )); + } } diff --git a/crates/simplex_agent_proto/src/error.rs b/crates/simplex_agent_proto/src/error.rs @@ -11,6 +11,7 @@ pub enum RadrootsSimplexAgentProtoError { InvalidShortFieldLength(usize), InvalidLargeFieldLength(usize), InvalidBoolEncoding(u8), + InvalidRatchetHeader(String), TrailingBytes, } @@ -36,6 +37,9 @@ impl fmt::Display for RadrootsSimplexAgentProtoError { Self::InvalidBoolEncoding(value) => { write!(f, "invalid SimpleX agent bool encoding `{value}`") } + Self::InvalidRatchetHeader(error) => { + write!(f, "invalid SimpleX agent ratchet header: {error}") + } Self::TrailingBytes => write!(f, "trailing bytes after SimpleX agent decode"), } } diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -45,6 +45,10 @@ use std::path::{Path, PathBuf}; const SIMPLEX_E2E_CONFIRMATION_LENGTH: usize = 15_904; const SIMPLEX_E2E_MESSAGE_LENGTH: usize = 16_000; +const SIMPLEX_AGENT_E2E_CONN_INFO_LENGTH: usize = 14_832; +const SIMPLEX_AGENT_E2E_CONN_INFO_PQ_LENGTH: usize = 11_106; +const SIMPLEX_AGENT_E2E_MESSAGE_LENGTH: usize = 15_840; +const SIMPLEX_AGENT_E2E_MESSAGE_PQ_LENGTH: usize = 13_618; #[derive(Debug, Clone)] struct SimplexClientMessageEnvelope { @@ -53,6 +57,12 @@ struct SimplexClientMessageEnvelope { ciphertext: Vec<u8>, } +#[derive(Debug, Clone, Copy)] +enum SimplexAgentPayloadKind { + ConnectionInfo, + Message, +} + #[derive(Debug, Clone)] struct SimplexReceivedBody { timestamp: u64, @@ -354,7 +364,7 @@ impl RadrootsSimplexAgentRuntime { encode_decrypted_message(&RadrootsSimplexAgentDecryptedMessage::ConnectionInfo( local_info, ))?, - None, + SimplexAgentPayloadKind::ConnectionInfo, )?; self.store.enqueue_command( connection_id, @@ -456,7 +466,11 @@ impl RadrootsSimplexAgentRuntime { let prepared = self .store .prepare_outbound_message(connection_id, message_hash.clone())?; - let encrypted = self.next_encrypted_payload(connection_id, ciphertext, None)?; + let encrypted = self.next_encrypted_payload( + connection_id, + ciphertext, + SimplexAgentPayloadKind::Message, + )?; self.store.enqueue_command( connection_id, RadrootsSimplexAgentPendingCommandKind::SendEnvelope { @@ -1228,7 +1242,11 @@ impl RadrootsSimplexAgentRuntime { let prepared = self .store .prepare_outbound_message(connection_id, message_hash)?; - let encrypted = self.next_encrypted_payload(connection_id, ciphertext, None)?; + let encrypted = self.next_encrypted_payload( + connection_id, + ciphertext, + SimplexAgentPayloadKind::Message, + )?; self.store.enqueue_command( connection_id, RadrootsSimplexAgentPendingCommandKind::SendEnvelope { @@ -1261,10 +1279,19 @@ impl RadrootsSimplexAgentRuntime { )) })?; let sender_public_key = match envelope { - RadrootsSimplexAgentEnvelope::Confirmation { encrypted, .. } => encrypted - .ratchet_header - .as_ref() - .map(|header| header.dh_public_key.clone()), + RadrootsSimplexAgentEnvelope::Confirmation { + reply_queue: true, .. + } => Some( + self.store + .connection(connection_id)? + .local_e2e_public_key + .clone() + .ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` is missing local E2E public key" + )) + })?, + ), _ => None, }; let mut body = Vec::with_capacity(1 + 512); @@ -1368,7 +1395,7 @@ impl RadrootsSimplexAgentRuntime { invitation, }); } - if let Some((reply_descriptor, sender_public_key)) = join_confirmation { + if let Some((reply_descriptor, _sender_public_key)) = join_confirmation { let send_queue = self.store.primary_send_queue(&command.connection_id)?; let confirmation_payload = self.next_encrypted_payload( &command.connection_id, @@ -1378,7 +1405,7 @@ impl RadrootsSimplexAgentRuntime { info: Vec::new(), }, )?, - Some(sender_public_key), + SimplexAgentPayloadKind::ConnectionInfo, )?; self.store.enqueue_command( &command.connection_id, @@ -1416,7 +1443,7 @@ impl RadrootsSimplexAgentRuntime { if let Some(shared_secret) = derived_secret { self.store.connection_mut(connection_id)?.shared_secret = Some(shared_secret); } - let decrypted = extract_decrypted_message(&envelope)?; + let decrypted = self.extract_decrypted_message(connection_id, &envelope)?; { let connection = self.store.connection_mut(connection_id)?; connection.last_received_queue = Some(queue.clone()); @@ -1520,42 +1547,117 @@ impl RadrootsSimplexAgentRuntime { fn next_encrypted_payload( &mut self, connection_id: &str, - ciphertext: Vec<u8>, - sender_public_key: Option<Vec<u8>>, + plaintext: Vec<u8>, + payload_kind: SimplexAgentPayloadKind, ) -> Result<RadrootsSimplexAgentEncryptedPayload, RadrootsSimplexAgentRuntimeError> { - let ratchet_header = self + let shared_secret = self + .store + .connection(connection_id)? + .shared_secret + .clone() + .ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` has no shared secret" + )) + })?; + let padded_len = self.agent_payload_padded_len(connection_id, payload_kind)?; + let (ratchet_header, ciphertext) = self .store .connection_mut(connection_id)? .ratchet_state .as_mut() - .map(|state| { - state - .next_outbound_header() - .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string())) - }) - .transpose()?; - let ratchet_header = match (ratchet_header, sender_public_key) { - (Some(mut header), Some(public_key)) => { - header.dh_public_key = public_key; - Some(header) - } - (None, Some(public_key)) => Some( - radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpRatchetHeader { - previous_sending_chain_length: 0, - message_number: 0, - dh_public_key: public_key, - pq_public_key: None, - pq_ciphertext: None, - }, - ), - (header, None) => header, - }; + .ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` has no ratchet state" + )) + })? + .encrypt_payload(&shared_secret, &plaintext, padded_len) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; Ok(RadrootsSimplexAgentEncryptedPayload { - ratchet_header, + ratchet_header: Some(ratchet_header), ciphertext, }) } + fn extract_decrypted_message( + &mut self, + connection_id: &str, + envelope: &RadrootsSimplexAgentEnvelope, + ) -> Result<RadrootsSimplexAgentDecryptedMessage, RadrootsSimplexAgentRuntimeError> { + match envelope { + RadrootsSimplexAgentEnvelope::Confirmation { encrypted, .. } + | RadrootsSimplexAgentEnvelope::Message(encrypted) + | RadrootsSimplexAgentEnvelope::RatchetKey { encrypted, .. } => { + let plaintext = self.decrypt_agent_payload(connection_id, encrypted)?; + decode_decrypted_message(&plaintext).map_err(Into::into) + } + RadrootsSimplexAgentEnvelope::Invitation { + connection_info, .. + } => decode_decrypted_message(connection_info).map_err(Into::into), + } + } + + fn decrypt_agent_payload( + &mut self, + connection_id: &str, + encrypted: &RadrootsSimplexAgentEncryptedPayload, + ) -> Result<Vec<u8>, RadrootsSimplexAgentRuntimeError> { + let shared_secret = self + .store + .connection(connection_id)? + .shared_secret + .clone() + .ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` has no shared secret" + )) + })?; + let header = encrypted.ratchet_header.as_ref().ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` received agent payload without ratchet header" + )) + })?; + 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_payload(&shared_secret, header, &encrypted.ciphertext) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string())) + } + + fn agent_payload_padded_len( + &self, + connection_id: &str, + payload_kind: SimplexAgentPayloadKind, + ) -> Result<usize, RadrootsSimplexAgentRuntimeError> { + let ratchet = self + .store + .connection(connection_id)? + .ratchet_state + .as_ref() + .ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{connection_id}` has no ratchet state" + )) + })?; + let pq_enabled = ratchet.current_pq_public_key.is_some() + || ratchet.remote_pq_public_key.is_some() + || ratchet.current_pq_shared_secret.is_some(); + Ok(match (payload_kind, pq_enabled) { + (SimplexAgentPayloadKind::ConnectionInfo, true) => { + SIMPLEX_AGENT_E2E_CONN_INFO_PQ_LENGTH + } + (SimplexAgentPayloadKind::ConnectionInfo, false) => SIMPLEX_AGENT_E2E_CONN_INFO_LENGTH, + (SimplexAgentPayloadKind::Message, true) => SIMPLEX_AGENT_E2E_MESSAGE_PQ_LENGTH, + (SimplexAgentPayloadKind::Message, false) => SIMPLEX_AGENT_E2E_MESSAGE_LENGTH, + }) + } + #[cfg(feature = "std")] fn flush_store(&self) -> Result<(), RadrootsSimplexAgentRuntimeError> { self.store.flush().map_err(Into::into) @@ -1769,21 +1871,6 @@ fn decode_received_body( }) } -fn extract_decrypted_message( - envelope: &RadrootsSimplexAgentEnvelope, -) -> Result<RadrootsSimplexAgentDecryptedMessage, RadrootsSimplexAgentRuntimeError> { - match envelope { - RadrootsSimplexAgentEnvelope::Confirmation { encrypted, .. } - | RadrootsSimplexAgentEnvelope::Message(encrypted) - | RadrootsSimplexAgentEnvelope::RatchetKey { encrypted, .. } => { - decode_decrypted_message(&encrypted.ciphertext).map_err(Into::into) - } - RadrootsSimplexAgentEnvelope::Invitation { - connection_info, .. - } => decode_decrypted_message(connection_info).map_err(Into::into), - } -} - #[cfg(test)] mod tests { use super::*; @@ -2288,6 +2375,67 @@ mod tests { } #[test] + fn send_message_stores_opaque_encrypted_agent_payload() { + let mut runtime = RadrootsSimplexAgentRuntimeBuilder::new().build().unwrap(); + let created = runtime + .create_connection(invitation_queue(), b"e2e".to_vec(), false, 10) + .unwrap(); + let invitation = runtime + .store + .connection(&created) + .unwrap() + .invitation + .clone() + .unwrap(); + let joined = runtime + .join_connection(invitation, reply_queue(), 20) + .unwrap(); + + let mut setup_transport = ScriptedTransport::with_responses(vec![ + ids_response(b"recipient", b"sender", b"server-dh"), + RadrootsSimplexSmpBrokerMessage::Ok, + ids_response(b"recipient-2", b"sender-2", b"server-dh-2"), + RadrootsSimplexSmpBrokerMessage::Ok, + RadrootsSimplexSmpBrokerMessage::Ok, + RadrootsSimplexSmpBrokerMessage::Ok, + ]); + runtime + .execute_ready_commands(&mut setup_transport, 30, 16) + .unwrap(); + mark_connected(&mut runtime, &joined); + + runtime + .send_message(&joined, b"hello simplex".to_vec(), 40) + .unwrap(); + let command = runtime.retry_pending(40, 16).remove(0); + let RadrootsSimplexAgentPendingCommandKind::SendEnvelope { envelope, .. } = command.kind + else { + panic!("expected send envelope command"); + }; + let RadrootsSimplexAgentEnvelope::Message(encrypted) = envelope else { + panic!("expected encrypted message envelope"); + }; + let expected_plaintext = encode_decrypted_message( + &RadrootsSimplexAgentDecryptedMessage::Message(RadrootsSimplexAgentMessageFrame { + header: RadrootsSimplexAgentMessageHeader { + message_id: 1, + previous_message_hash: Vec::new(), + }, + message: RadrootsSimplexAgentMessage::UserMessage(b"hello simplex".to_vec()), + padding: Vec::new(), + }), + ) + .unwrap(); + + assert!(encrypted.ratchet_header.is_some()); + assert_ne!(encrypted.ciphertext, expected_plaintext); + assert_eq!( + encrypted.ciphertext.len(), + SIMPLEX_AGENT_E2E_MESSAGE_LENGTH + 16 + ); + } + + #[test] fn transport_retry_keeps_staged_outbound_message() { let mut runtime = RadrootsSimplexAgentRuntimeBuilder::new().build().unwrap(); let created = runtime diff --git a/crates/simplex_interop_tests/src/lib.rs b/crates/simplex_interop_tests/src/lib.rs @@ -252,24 +252,15 @@ mod tests { let envelope = RadrootsSimplexAgentEnvelope::Message(RadrootsSimplexAgentEncryptedPayload { ratchet_header: None, - ciphertext: encoded_decrypted.clone(), + ciphertext: b"opaque-agent-ciphertext".to_vec(), }); let decoded_envelope = decode_envelope(&encode_envelope(&envelope).unwrap()).unwrap(); let RadrootsSimplexAgentEnvelope::Message(payload) = decoded_envelope else { panic!("expected message envelope"); }; - let decoded_decrypted = decode_decrypted_message(&payload.ciphertext).unwrap(); - let RadrootsSimplexAgentDecryptedMessage::Message(decoded_frame_from_envelope) = - decoded_decrypted - else { - panic!("expected message frame"); - }; - let RadrootsSimplexAgentMessage::UserMessage(encoded_chat_again) = - decoded_frame_from_envelope.message - else { - panic!("expected user message"); - }; - assert_eq!(decode_messages(&encoded_chat_again).unwrap(), chat_messages); + assert_eq!(payload.ciphertext, b"opaque-agent-ciphertext".to_vec()); + let decoded_decrypted = decode_decrypted_message(&encoded_decrypted).unwrap(); + assert_eq!(decoded_decrypted, decrypted); } #[test] diff --git a/crates/simplex_smp_crypto/src/ratchet.rs b/crates/simplex_smp_crypto/src/ratchet.rs @@ -1,5 +1,15 @@ use crate::error::RadrootsSimplexSmpCryptoError; +use crate::message::{ + RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH, decrypt_padded, + encrypt_padded, +}; use alloc::vec::Vec; +use hkdf::Hkdf; +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; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsSimplexSmpRatchetRole { @@ -185,6 +195,49 @@ impl RadrootsSimplexSmpRatchetState { self.root_epoch = self.root_epoch.saturating_add(1); Ok(()) } + + pub fn encrypt_payload( + &mut self, + shared_secret: &[u8], + plaintext: &[u8], + padded_len: usize, + ) -> Result<(RadrootsSimplexSmpRatchetHeader, Vec<u8>), RadrootsSimplexSmpCryptoError> { + let header = self.next_outbound_header()?; + let associated_data = ratchet_header_associated_data(&header)?; + let (message_key, nonce) = derive_ratchet_message_key( + shared_secret, + self.current_pq_shared_secret.as_deref(), + self.root_epoch, + &associated_data, + )?; + let ciphertext = encrypt_padded(&message_key, &nonce, plaintext, padded_len)?; + Ok((header, ciphertext)) + } + + pub fn decrypt_payload( + &mut self, + shared_secret: &[u8], + header: &RadrootsSimplexSmpRatchetHeader, + ciphertext: &[u8], + ) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + header.validate()?; + if header.message_number < self.receiving_chain_length { + return Err(RadrootsSimplexSmpCryptoError::RatchetMessageRegression { + received: header.message_number, + current: self.receiving_chain_length, + }); + } + let associated_data = ratchet_header_associated_data(header)?; + let (message_key, nonce) = derive_ratchet_message_key( + shared_secret, + self.current_pq_shared_secret.as_deref(), + self.root_epoch, + &associated_data, + )?; + let plaintext = decrypt_padded(&message_key, &nonce, ciphertext)?; + self.apply_inbound_header(header, None)?; + Ok(plaintext) + } } fn validate_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { @@ -194,6 +247,79 @@ fn validate_public_key(value: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError Ok(()) } +fn derive_ratchet_message_key( + shared_secret: &[u8], + pq_shared_secret: Option<&[u8]>, + root_epoch: u64, + associated_data: &[u8], +) -> Result<(Vec<u8>, [u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH]), RadrootsSimplexSmpCryptoError> { + let mut ikm = Vec::with_capacity(shared_secret.len() + pq_shared_secret.map_or(0, <[u8]>::len)); + ikm.extend_from_slice(shared_secret); + if let Some(secret) = pq_shared_secret { + ikm.extend_from_slice(secret); + } + let mut salt = Vec::with_capacity(8 + associated_data.len()); + salt.extend_from_slice(&root_epoch.to_be_bytes()); + salt.extend_from_slice(associated_data); + let hkdf = Hkdf::<Sha512>::new(Some(&salt), &ikm); + let mut output = [0_u8; RADROOTS_SIMPLEX_AGENT_RATCHET_OUTPUT_LENGTH]; + hkdf.expand(RADROOTS_SIMPLEX_AGENT_RATCHET_INFO, &mut output) + .map_err(|_| { + RadrootsSimplexSmpCryptoError::InvalidKeyDerivationLength( + RADROOTS_SIMPLEX_AGENT_RATCHET_OUTPUT_LENGTH, + ) + })?; + let mut nonce = [0_u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH]; + nonce.copy_from_slice(&output[RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH..]); + Ok(( + output[..RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH].to_vec(), + nonce, + )) +} + +fn ratchet_header_associated_data( + header: &RadrootsSimplexSmpRatchetHeader, +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let mut buffer = Vec::new(); + buffer.extend_from_slice(&header.previous_sending_chain_length.to_be_bytes()); + buffer.extend_from_slice(&header.message_number.to_be_bytes()); + push_large_bytes(&mut buffer, &header.dh_public_key)?; + push_maybe_large_bytes(&mut buffer, header.pq_public_key.as_deref())?; + push_maybe_large_bytes(&mut buffer, header.pq_ciphertext.as_deref())?; + Ok(buffer) +} + +fn push_maybe_large_bytes( + buffer: &mut Vec<u8>, + value: Option<&[u8]>, +) -> Result<(), RadrootsSimplexSmpCryptoError> { + match value { + Some(value) => { + buffer.push(1); + push_large_bytes(buffer, value) + } + None => { + buffer.push(0); + Ok(()) + } + } +} + +fn push_large_bytes( + buffer: &mut Vec<u8>, + value: &[u8], +) -> Result<(), RadrootsSimplexSmpCryptoError> { + if value.len() > u16::MAX as usize { + return Err(RadrootsSimplexSmpCryptoError::InvalidMessageLength { + actual: value.len(), + padded: u16::MAX as usize, + }); + } + buffer.extend_from_slice(&(value.len() as u16).to_be_bytes()); + buffer.extend_from_slice(value); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -277,4 +403,79 @@ mod tests { let error = header.validate().unwrap_err(); assert_eq!(error, RadrootsSimplexSmpCryptoError::IncompletePqHeader); } + + #[test] + fn encrypts_payload_and_advances_receive_state() { + 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 = [7_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + + let (header, ciphertext) = sender + .encrypt_payload(&shared_secret, b"agent body", 64) + .unwrap(); + + assert_ne!(ciphertext, b"agent body"); + let plaintext = receiver + .decrypt_payload(&shared_secret, &header, &ciphertext) + .unwrap(); + assert_eq!(plaintext, b"agent body"); + assert_eq!(receiver.receiving_chain_length, 1); + } + + #[test] + fn rejects_tampered_ratchet_header() { + 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 = [9_u8; RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH]; + let (mut header, ciphertext) = sender + .encrypt_payload(&shared_secret, b"agent body", 64) + .unwrap(); + header.message_number = header.message_number.saturating_add(1); + + let error = receiver + .decrypt_payload(&shared_secret, &header, &ciphertext) + .unwrap_err(); + assert!(matches!( + error, + RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(_) + )); + } + + #[test] + fn stages_large_pq_material_in_header() { + let mut sender = RadrootsSimplexSmpRatchetState::initiator( + b"alice-dh".to_vec(), + b"bob-dh".to_vec(), + None, + ) + .unwrap(); + sender + .stage_outbound_pq_step(vec![1_u8; 1158], vec![2_u8; 1039], vec![3_u8; 32]) + .unwrap(); + + let header = sender.next_outbound_header().unwrap(); + assert_eq!(header.pq_public_key.as_ref().unwrap().len(), 1158); + assert_eq!(header.pq_ciphertext.as_ref().unwrap().len(), 1039); + assert!(ratchet_header_associated_data(&header).unwrap().len() > 2200); + } }