lib

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

commit 5e24621e7386d99866f5a0fbf2b5104ea4413a08
parent 959622c8304bab5c5f9cff08636ac44a6fddee50
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 00:12:27 +0000

simplex: carry confirmation x3dh params

- add optional X3DH params to agent confirmation envelopes
- persist local X3DH keypairs on connection records
- derive join confirmation params from persisted sender keys
- cover confirmation params in protocol, store, and runtime tests

Diffstat:
Mcrates/simplex_agent_proto/src/codec.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/simplex_agent_proto/src/model.rs | 1+
Mcrates/simplex_agent_runtime/src/runtime.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/simplex_agent_store/src/lib.rs | 2+-
Mcrates/simplex_agent_store/src/store.rs | 39+++++++++++++++++++++++++++++++++++++++
5 files changed, 233 insertions(+), 21 deletions(-)

diff --git a/crates/simplex_agent_proto/src/codec.rs b/crates/simplex_agent_proto/src/codec.rs @@ -9,8 +9,8 @@ use crate::model::{ }; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpRatchetHeader; use radroots_simplex_smp_crypto::prelude::{ + RadrootsSimplexOfficialX3dhParams, RadrootsSimplexSmpRatchetHeader, decode_official_x3dh_params_uri, encode_official_x3dh_params_uri, }; use radroots_simplex_smp_proto::prelude::{ @@ -141,10 +141,12 @@ pub fn encode_envelope( match envelope { RadrootsSimplexAgentEnvelope::Confirmation { reply_queue, + e2e_ratchet_params, encrypted, } => { buffer.push(b'C'); buffer.push(encode_bool(*reply_queue)); + encode_optional_x3dh_params(&mut buffer, e2e_ratchet_params)?; encode_encrypted_payload(&mut buffer, encrypted)?; } RadrootsSimplexAgentEnvelope::Message(encrypted) => { @@ -176,10 +178,12 @@ pub fn decode_envelope( match cursor.read_byte()? { b'C' => { let reply_queue = decode_bool(cursor.read_byte()?)?; + let e2e_ratchet_params = decode_optional_x3dh_params(&mut cursor)?; let encrypted = decode_encrypted_payload(&mut cursor)?; cursor.finish()?; Ok(RadrootsSimplexAgentEnvelope::Confirmation { reply_queue, + e2e_ratchet_params, encrypted, }) } @@ -347,6 +351,45 @@ fn decode_encrypted_payload( } } +fn encode_optional_x3dh_params( + buffer: &mut Vec<u8>, + params: &Option<RadrootsSimplexOfficialX3dhParams>, +) -> Result<(), RadrootsSimplexAgentProtoError> { + match params { + Some(params) => { + buffer.push(b'1'); + let encoded = encode_official_x3dh_params_uri(params).map_err(|error| { + RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()) + })?; + push_short_bytes(buffer, encoded.as_bytes()) + } + None => { + buffer.push(b'0'); + Ok(()) + } + } +} + +fn decode_optional_x3dh_params( + cursor: &mut Cursor<'_>, +) -> Result<Option<RadrootsSimplexOfficialX3dhParams>, RadrootsSimplexAgentProtoError> { + match cursor.read_byte()? { + b'0' => Ok(None), + b'1' => { + let encoded = String::from_utf8(cursor.read_short_bytes()?) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidUtf8(error.to_string()))?; + decode_official_x3dh_params_uri(&encoded) + .map(Some) + .map_err(|error| { + RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()) + }) + } + tag => Err(RadrootsSimplexAgentProtoError::InvalidTag( + String::from_utf8_lossy(&[tag]).into_owned(), + )), + } +} + fn encode_queue_descriptor( buffer: &mut Vec<u8>, descriptor: &RadrootsSimplexAgentQueueDescriptor, @@ -748,6 +791,23 @@ mod tests { } #[test] + fn roundtrips_confirmation_x3dh_params() { + let envelope = RadrootsSimplexAgentEnvelope::Confirmation { + reply_queue: true, + e2e_ratchet_params: Some(sample_x3dh_params()), + encrypted: RadrootsSimplexAgentEncryptedPayload { + ratchet_header: None, + official_message: Some(b"official".to_vec()), + ciphertext: Vec::new(), + }, + }; + + let encoded = encode_envelope(&envelope).unwrap(); + let decoded = decode_envelope(&encoded).unwrap(); + assert_eq!(decoded, envelope); + } + + #[test] fn roundtrips_message_frame_and_envelope() { let descriptor = RadrootsSimplexAgentQueueDescriptor { queue_uri: sample_queue_uri(), diff --git a/crates/simplex_agent_proto/src/model.rs b/crates/simplex_agent_proto/src/model.rs @@ -126,6 +126,7 @@ pub struct RadrootsSimplexAgentEncryptedPayload { pub enum RadrootsSimplexAgentEnvelope { Confirmation { reply_queue: bool, + e2e_ratchet_params: Option<RadrootsSimplexOfficialX3dhParams>, encrypted: RadrootsSimplexAgentEncryptedPayload, }, Message(RadrootsSimplexAgentEncryptedPayload), diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -18,15 +18,16 @@ use radroots_simplex_agent_proto::prelude::{ use radroots_simplex_agent_store::prelude::{ RadrootsSimplexAgentOutboundMessage, RadrootsSimplexAgentPendingCommand, RadrootsSimplexAgentPendingCommandKind, RadrootsSimplexAgentQueueRole, - RadrootsSimplexAgentStore, + RadrootsSimplexAgentStore, RadrootsSimplexAgentX3dhKeypair, }; use radroots_simplex_smp_crypto::prelude::{ RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RadrootsSimplexOfficialX3dhParams, - RadrootsSimplexSmpCommandAuthorization, RadrootsSimplexSmpCryptoError, - RadrootsSimplexSmpRatchetState, RadrootsSimplexSmpX25519Keypair, decode_x25519_public_key_x509, - decrypt_padded, derive_shared_secret, encode_ed25519_public_key_x509, - encode_x25519_public_key_x509, encrypt_padded, official_x448_keypair_from_seed, random_nonce, + RadrootsSimplexOfficialX448Keypair, RadrootsSimplexSmpCommandAuthorization, + RadrootsSimplexSmpCryptoError, RadrootsSimplexSmpRatchetState, RadrootsSimplexSmpX25519Keypair, + decode_x25519_public_key_x509, decrypt_padded, derive_shared_secret, + encode_ed25519_public_key_x509, encode_x25519_public_key_x509, encrypt_padded, + official_x448_keypair_from_seed, random_nonce, }; use radroots_simplex_smp_proto::prelude::{ RADROOTS_SIMPLEX_SMP_CURRENT_CLIENT_VERSION, RADROOTS_SIMPLEX_SMP_CURRENT_TRANSPORT_VERSION, @@ -197,8 +198,8 @@ impl RadrootsSimplexAgentRuntime { pq_ciphertext: None, }; let ratchet_state = RadrootsSimplexSmpRatchetState::initiator( - x3dh_key_2.public_key, - x3dh_key_1.public_key, + x3dh_key_2.public_key.clone(), + x3dh_key_1.public_key.clone(), None, ) .ok(); @@ -239,6 +240,8 @@ impl RadrootsSimplexAgentRuntime { let connection = self.store.connection_mut(&connection.id)?; connection.local_e2e_public_key = Some(e2e_keypair.public_key); connection.local_e2e_private_key = Some(e2e_keypair.private_key); + connection.local_x3dh_key_1 = Some(agent_x3dh_keypair(x3dh_key_1)); + connection.local_x3dh_key_2 = Some(agent_x3dh_keypair(x3dh_key_2)); let queue = connection .queues .iter_mut() @@ -277,17 +280,24 @@ impl RadrootsSimplexAgentRuntime { .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; reply_queue.sender_id = placeholder_sender_id(invitation.connection_id.as_slice(), &now.to_be_bytes()); - let local_dh_public_key = official_x448_keypair_from_seed(&derive_material( - b"connection-join-local-dh", + let local_x3dh_key_1 = official_x448_keypair_from_seed(&derive_material( + b"connection-join-x3dh-1", &[ invitation.connection_id.as_slice(), reply_queue.to_string().as_bytes(), &now.to_be_bytes(), ], - )) - .public_key; + )); + let local_x3dh_key_2 = official_x448_keypair_from_seed(&derive_material( + b"connection-join-x3dh-2", + &[ + invitation.connection_id.as_slice(), + reply_queue.to_string().as_bytes(), + &now.to_be_bytes(), + ], + )); let ratchet_state = RadrootsSimplexSmpRatchetState::responder( - local_dh_public_key, + local_x3dh_key_2.public_key.clone(), invitation.e2e_ratchet_params.key_2.clone(), None, ) @@ -332,6 +342,8 @@ impl RadrootsSimplexAgentRuntime { let connection = self.store.connection_mut(&connection.id)?; connection.local_e2e_public_key = Some(local_e2e_keypair.public_key.clone()); connection.local_e2e_private_key = Some(local_e2e_keypair.private_key); + connection.local_x3dh_key_1 = Some(agent_x3dh_keypair(local_x3dh_key_1)); + connection.local_x3dh_key_2 = Some(agent_x3dh_keypair(local_x3dh_key_2)); connection.shared_secret = Some(shared_secret); let queue = connection .queues @@ -394,6 +406,7 @@ impl RadrootsSimplexAgentRuntime { queue: send_queue.descriptor.queue_address(), envelope: RadrootsSimplexAgentEnvelope::Confirmation { reply_queue: false, + e2e_ratchet_params: None, encrypted, }, delivery: None, @@ -1391,14 +1404,24 @@ impl RadrootsSimplexAgentRuntime { invitation_event = Some(invitation.clone()); } } else if connection.status == RadrootsSimplexAgentConnectionStatus::JoinPending { + let local_x3dh_key_1 = connection.local_x3dh_key_1.as_ref().ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{}` missing local X3DH key 1", + command.connection_id + )) + })?; + let local_x3dh_key_2 = connection.local_x3dh_key_2.as_ref().ok_or_else(|| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "SimpleX connection `{}` missing local X3DH key 2", + command.connection_id + )) + })?; join_confirmation = Some(( queue.descriptor.clone(), - connection.local_e2e_public_key.clone().ok_or_else(|| { - RadrootsSimplexAgentRuntimeError::Runtime(format!( - "SimpleX connection `{}` missing local E2E public key", - command.connection_id - )) - })?, + official_x3dh_params_from_keys( + &local_x3dh_key_1.public_key, + &local_x3dh_key_2.public_key, + )?, )); } } @@ -1417,7 +1440,7 @@ impl RadrootsSimplexAgentRuntime { invitation, }); } - if let Some((reply_descriptor, _sender_public_key)) = join_confirmation { + if let Some((reply_descriptor, e2e_ratchet_params)) = join_confirmation { let send_queue = self.store.primary_send_queue(&command.connection_id)?; let confirmation_payload = self.next_encrypted_payload( &command.connection_id, @@ -1435,6 +1458,7 @@ impl RadrootsSimplexAgentRuntime { queue: send_queue.descriptor.queue_address(), envelope: RadrootsSimplexAgentEnvelope::Confirmation { reply_queue: true, + e2e_ratchet_params: Some(e2e_ratchet_params), encrypted: confirmation_payload, }, delivery: None, @@ -1716,6 +1740,32 @@ fn derive_material(label: &[u8], parts: &[&[u8]]) -> Vec<u8> { hasher.finalize().to_vec() } +fn agent_x3dh_keypair( + keypair: RadrootsSimplexOfficialX448Keypair, +) -> RadrootsSimplexAgentX3dhKeypair { + RadrootsSimplexAgentX3dhKeypair { + public_key: keypair.public_key, + private_key: keypair.private_key, + } +} + +fn official_x3dh_params_from_keys( + key_1: &[u8], + key_2: &[u8], +) -> Result<RadrootsSimplexOfficialX3dhParams, RadrootsSimplexAgentRuntimeError> { + Ok(RadrootsSimplexOfficialX3dhParams { + version_range: RadrootsSimplexSmpVersionRange::new( + RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + ) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?, + key_1: key_1.to_vec(), + key_2: key_2.to_vec(), + pq_public_key: None, + pq_ciphertext: None, + }) +} + fn correlation_id_for_command(command_id: u64) -> RadrootsSimplexSmpCorrelationId { let digest = derive_material(b"simplex-command-correlation", &[&command_id.to_be_bytes()]); let mut correlation = [0_u8; RadrootsSimplexSmpCorrelationId::LENGTH]; @@ -2177,6 +2227,68 @@ mod tests { } #[test] + fn join_confirmation_carries_sender_x3dh_params() { + 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 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"), + ]); + runtime + .execute_ready_commands(&mut transport, 30, 3) + .unwrap(); + let local_key_1 = runtime + .store + .connection(&joined) + .unwrap() + .local_x3dh_key_1 + .clone() + .unwrap(); + let local_key_2 = runtime + .store + .connection(&joined) + .unwrap() + .local_x3dh_key_2 + .clone() + .unwrap(); + let ready = runtime.retry_pending(30, 16); + let confirmation_params = ready + .into_iter() + .find_map(|command| match command.kind { + RadrootsSimplexAgentPendingCommandKind::SendEnvelope { + envelope: + RadrootsSimplexAgentEnvelope::Confirmation { + reply_queue: true, + e2e_ratchet_params: Some(params), + .. + }, + .. + } => Some(params), + _ => None, + }) + .unwrap(); + + assert_eq!(confirmation_params.key_1, local_key_1.public_key); + assert_eq!(confirmation_params.key_2, local_key_2.public_key); + assert!(confirmation_params.pq_public_key.is_none()); + assert!(confirmation_params.pq_ciphertext.is_none()); + } + + #[test] fn explicit_get_connection_message_executes_smp_get() { let mut runtime = RadrootsSimplexAgentRuntimeBuilder::new().build().unwrap(); let created = runtime diff --git a/crates/simplex_agent_store/src/lib.rs b/crates/simplex_agent_store/src/lib.rs @@ -14,6 +14,6 @@ pub mod prelude { RadrootsSimplexAgentPendingCommandKind, RadrootsSimplexAgentPreparedOutboundMessage, RadrootsSimplexAgentQueueAuthState, RadrootsSimplexAgentQueueRecord, RadrootsSimplexAgentQueueRole, RadrootsSimplexAgentRecentMessageRecord, - RadrootsSimplexAgentStore, + RadrootsSimplexAgentStore, RadrootsSimplexAgentX3dhKeypair, }; } diff --git a/crates/simplex_agent_store/src/store.rs b/crates/simplex_agent_store/src/store.rs @@ -79,6 +79,13 @@ pub struct RadrootsSimplexAgentPreparedOutboundMessage { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +pub struct RadrootsSimplexAgentX3dhKeypair { + pub public_key: Vec<u8>, + pub private_key: Vec<u8>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum RadrootsSimplexAgentPendingCommandKind { CreateQueue { descriptor: RadrootsSimplexAgentQueueDescriptor, @@ -131,6 +138,8 @@ pub struct RadrootsSimplexAgentConnectionRecord { pub ratchet_state: Option<RadrootsSimplexSmpRatchetState>, pub local_e2e_public_key: Option<Vec<u8>>, pub local_e2e_private_key: Option<Vec<u8>>, + pub local_x3dh_key_1: Option<RadrootsSimplexAgentX3dhKeypair>, + pub local_x3dh_key_2: Option<RadrootsSimplexAgentX3dhKeypair>, pub shared_secret: Option<Vec<u8>>, pub delivery_cursor: RadrootsSimplexAgentDeliveryCursor, pub last_received_queue: Option<RadrootsSimplexAgentQueueAddress>, @@ -161,6 +170,8 @@ struct RadrootsSimplexAgentConnectionSnapshot { ratchet_state: Option<RadrootsSimplexAgentRatchetStateSnapshot>, local_e2e_public_key: Option<Vec<u8>>, local_e2e_private_key: Option<Vec<u8>>, + local_x3dh_key_1: Option<RadrootsSimplexAgentX3dhKeypair>, + local_x3dh_key_2: Option<RadrootsSimplexAgentX3dhKeypair>, shared_secret: Option<Vec<u8>>, delivery_cursor: RadrootsSimplexAgentDeliveryCursor, last_received_queue: Option<RadrootsSimplexAgentQueueAddressSnapshot>, @@ -371,6 +382,8 @@ impl RadrootsSimplexAgentStore { ratchet_state, local_e2e_public_key: None, local_e2e_private_key: None, + local_x3dh_key_1: None, + local_x3dh_key_2: None, shared_secret: None, delivery_cursor: RadrootsSimplexAgentDeliveryCursor { last_sent_message_id: None, @@ -846,6 +859,8 @@ fn connection_to_snapshot( ratchet_state: record.ratchet_state.map(ratchet_state_to_snapshot), local_e2e_public_key: record.local_e2e_public_key, local_e2e_private_key: record.local_e2e_private_key, + local_x3dh_key_1: record.local_x3dh_key_1, + local_x3dh_key_2: record.local_x3dh_key_2, shared_secret: record.shared_secret, delivery_cursor: record.delivery_cursor, last_received_queue: record.last_received_queue.map(queue_address_to_snapshot), @@ -887,6 +902,8 @@ fn connection_from_snapshot( .transpose()?, local_e2e_public_key: snapshot.local_e2e_public_key, local_e2e_private_key: snapshot.local_e2e_private_key, + local_x3dh_key_1: snapshot.local_x3dh_key_1, + local_x3dh_key_2: snapshot.local_x3dh_key_2, shared_secret: snapshot.shared_secret, delivery_cursor: snapshot.delivery_cursor, last_received_queue: snapshot @@ -1463,6 +1480,14 @@ mod tests { let connection = store.connection_mut(&connection.id).unwrap(); connection.hello_sent = true; connection.hello_received = true; + connection.local_x3dh_key_1 = Some(RadrootsSimplexAgentX3dhKeypair { + public_key: b"x3dh-public-1".to_vec(), + private_key: b"x3dh-private-1".to_vec(), + }); + connection.local_x3dh_key_2 = Some(RadrootsSimplexAgentX3dhKeypair { + public_key: b"x3dh-public-2".to_vec(), + private_key: b"x3dh-private-2".to_vec(), + }); } store.flush().unwrap(); @@ -1477,6 +1502,20 @@ mod tests { ); assert!(loaded_connection.hello_sent); assert!(loaded_connection.hello_received); + assert_eq!( + loaded_connection + .local_x3dh_key_1 + .as_ref() + .map(|key| (key.public_key.as_slice(), key.private_key.as_slice())), + Some((&b"x3dh-public-1"[..], &b"x3dh-private-1"[..])) + ); + assert_eq!( + loaded_connection + .local_x3dh_key_2 + .as_ref() + .map(|key| (key.public_key.as_slice(), key.private_key.as_slice())), + Some((&b"x3dh-public-2"[..], &b"x3dh-private-2"[..])) + ); assert_eq!(loaded.pending_commands.len(), 2); assert!(loaded.pending_commands.values().any(|command| matches!( &command.kind,