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:
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);
+ }
}