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