lib

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

commit 61c879e09f4388dc6fb40dbb9200038fb07042d6
parent 81d50491fea677a29d8d7594169cc1d8eeafd953
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 10:11:08 +0000

simplex: correlate peer receipts by hash

- expose persisted outbound message hashes from the agent store
- reject peer receipts that do not match stored outbound frame state
- include message hashes on runtime acknowledgment events
- cover matching and mismatched receipt correlation paths

Diffstat:
Mcrates/simplex_agent_runtime/src/runtime.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/simplex_agent_runtime/src/types.rs | 1+
Mcrates/simplex_agent_store/src/store.rs | 24++++++++++++++++++++++++
3 files changed, 129 insertions(+), 0 deletions(-)

diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -911,10 +911,26 @@ impl RadrootsSimplexAgentRuntime { } } RadrootsSimplexAgentMessage::Receipt(receipt) => { + let Some(stored_message_hash) = self + .store + .outbound_message_hash(connection_id, receipt.message_id)? + else { + return Err(RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX receipt for `{connection_id}` referenced unknown outbound message `{}`", + receipt.message_id + ))); + }; + if stored_message_hash != receipt.message_hash { + return Err(RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX receipt for `{connection_id}` message `{}` did not match stored outbound message hash", + receipt.message_id + ))); + } self.events .push_back(RadrootsSimplexAgentRuntimeEvent::MessageAcknowledged { connection_id: connection_id.into(), message_id: receipt.message_id, + message_hash: receipt.message_hash, }); } RadrootsSimplexAgentMessage::QueueAdd(_) @@ -2609,6 +2625,25 @@ mod tests { Sha256::digest(&encoded).to_vec() } + fn receipt_message( + frame_message_id: u64, + message_id: u64, + message_hash: Vec<u8>, + ) -> RadrootsSimplexAgentDecryptedMessage { + RadrootsSimplexAgentDecryptedMessage::Message(RadrootsSimplexAgentMessageFrame { + header: RadrootsSimplexAgentMessageHeader { + message_id: frame_message_id, + previous_message_hash: Vec::new(), + }, + message: RadrootsSimplexAgentMessage::Receipt(RadrootsSimplexAgentMessageReceipt { + message_id, + message_hash, + receipt_info: Vec::new(), + }), + padding: Vec::new(), + }) + } + fn mark_connected(runtime: &mut RadrootsSimplexAgentRuntime, connection_id: &str) { runtime .store @@ -3554,6 +3589,75 @@ mod tests { } #[test] + fn peer_receipt_requires_stored_outbound_message_hash() { + let mut runtime = RadrootsSimplexAgentRuntimeBuilder::new().build().unwrap(); + let connection_id = runtime + .create_connection(invitation_queue(), b"e2e".to_vec(), false, 10) + .unwrap(); + let prepared = runtime + .store + .prepare_outbound_message(&connection_id, b"outbound-message-hash".to_vec()) + .unwrap(); + runtime + .store + .confirm_outbound_message(&connection_id, prepared.message_id) + .unwrap(); + + runtime + .handle_inbound_decrypted_message( + &connection_id, + receipt_message(1, prepared.message_id, b"outbound-message-hash".to_vec()), + b"receipt-frame".to_vec(), + ) + .unwrap(); + + assert!(runtime.drain_events(16).into_iter().any(|event| matches!( + event, + RadrootsSimplexAgentRuntimeEvent::MessageAcknowledged { + connection_id: event_connection_id, + message_id, + message_hash, + } if event_connection_id == connection_id + && message_id == prepared.message_id + && message_hash == b"outbound-message-hash".to_vec() + ))); + } + + #[test] + fn peer_receipt_rejects_hash_mismatch() { + let mut runtime = RadrootsSimplexAgentRuntimeBuilder::new().build().unwrap(); + let connection_id = runtime + .create_connection(invitation_queue(), b"e2e".to_vec(), false, 10) + .unwrap(); + let prepared = runtime + .store + .prepare_outbound_message(&connection_id, b"outbound-message-hash".to_vec()) + .unwrap(); + runtime + .store + .confirm_outbound_message(&connection_id, prepared.message_id) + .unwrap(); + + let error = runtime + .handle_inbound_decrypted_message( + &connection_id, + receipt_message(1, prepared.message_id, b"wrong-hash".to_vec()), + b"receipt-frame".to_vec(), + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("did not match stored outbound message hash") + ); + assert!(!runtime.drain_events(16).into_iter().any(|event| matches!( + event, + RadrootsSimplexAgentRuntimeEvent::MessageAcknowledged { .. } + ))); + } + + #[test] fn send_message_stores_opaque_encrypted_agent_payload() { let mut runtime = RadrootsSimplexAgentRuntimeBuilder::new().build().unwrap(); let created = runtime diff --git a/crates/simplex_agent_runtime/src/types.rs b/crates/simplex_agent_runtime/src/types.rs @@ -42,6 +42,7 @@ pub enum RadrootsSimplexAgentRuntimeEvent { MessageAcknowledged { connection_id: String, message_id: u64, + message_hash: Vec<u8>, }, SubscriptionQueued { connection_id: String, diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs @@ -1216,6 +1216,24 @@ impl RadrootsSimplexAgentStore { })) } + pub fn outbound_message_hash( + &self, + connection_id: &str, + message_id: RadrootsSimplexAgentMessageId, + ) -> Result<Option<Vec<u8>>, RadrootsSimplexAgentStoreError> { + let connection = self.connection(connection_id)?; + Ok(connection + .recent_messages + .iter() + .rev() + .find(|message| { + message.message_id == message_id + && message.inbound_queue.is_none() + && message.inbound_broker_message_id.is_none() + }) + .map(|message| message.message_hash.clone())) + } + pub fn take_ready_commands( &mut self, now: u64, @@ -3626,6 +3644,12 @@ mod tests { let cursor = &store.connection(&connection.id).unwrap().delivery_cursor; assert_eq!(cursor.last_sent_message_id, Some(1)); assert_eq!(cursor.last_sent_message_hash, Some(b"ciphertext".to_vec())); + assert_eq!( + store + .outbound_message_hash(&connection.id, prepared.message_id) + .unwrap(), + Some(b"ciphertext".to_vec()) + ); } #[test]