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