commit 3dfc0b021e8c36d7eb839ddc69fe6806279a26b4
parent 6ea8cf9a4791ff593a34c7c3dd02d88546c4de83
Author: triesap <tyson@radroots.org>
Date: Mon, 22 Jun 2026 23:12:43 +0000
simplex: add official ratchet wire codec
- model encrypted official ratchet headers and messages as opaque wire payloads
- encode and decode versioned large fields with upstream-compatible length behavior
- expose official encoded header and message length helpers
- cover roundtrip and malformed wire length cases in crypto tests
Diffstat:
2 files changed, 305 insertions(+), 4 deletions(-)
diff --git a/crates/simplex_smp_crypto/src/lib.rs b/crates/simplex_smp_crypto/src/lib.rs
@@ -37,12 +37,16 @@ pub mod prelude {
RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_SHARED_SECRET_LENGTH,
RADROOTS_SIMPLEX_OFFICIAL_X3DH_INFO, RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH,
RADROOTS_SIMPLEX_OFFICIAL_X448_SHARED_SECRET_LENGTH, RadrootsSimplexOfficialAesGcmPayload,
- RadrootsSimplexOfficialChainKdfOutput, RadrootsSimplexOfficialRootKdfOutput,
+ RadrootsSimplexOfficialChainKdfOutput, RadrootsSimplexOfficialEncryptedHeader,
+ RadrootsSimplexOfficialEncryptedMessage, RadrootsSimplexOfficialRootKdfOutput,
RadrootsSimplexOfficialSntrup761Keypair, RadrootsSimplexOfficialX448Keypair,
- decapsulate_official_sntrup761, derive_official_x448_shared_secret,
- encapsulate_official_sntrup761, generate_official_sntrup761_keypair,
+ decapsulate_official_sntrup761, decode_official_encrypted_header,
+ decode_official_encrypted_message, derive_official_x448_shared_secret,
+ encapsulate_official_sntrup761, encode_official_encrypted_header,
+ encode_official_encrypted_message, generate_official_sntrup761_keypair,
generate_official_x448_keypair, official_aes_gcm_decrypt_padded,
- official_aes_gcm_encrypt_padded, official_chain_kdf, official_full_header_len,
+ official_aes_gcm_encrypt_padded, official_chain_kdf, official_encoded_encrypted_header_len,
+ official_encoded_encrypted_message_len, official_full_header_len,
official_ratchet_header_len, official_root_kdf, official_sntrup761_keypair_from_seed,
official_x448_keypair_from_seed,
};
diff --git a/crates/simplex_smp_crypto/src/official_ratchet.rs b/crates/simplex_smp_crypto/src/official_ratchet.rs
@@ -50,6 +50,21 @@ pub struct RadrootsSimplexOfficialAesGcmPayload {
}
#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSimplexOfficialEncryptedHeader {
+ pub version: u16,
+ pub iv: [u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH],
+ pub auth_tag: Vec<u8>,
+ pub body: Vec<u8>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsSimplexOfficialEncryptedMessage {
+ pub encrypted_header: Vec<u8>,
+ pub auth_tag: Vec<u8>,
+ pub body: Vec<u8>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RadrootsSimplexOfficialRootKdfOutput {
pub root_key: Vec<u8>,
pub chain_key: Vec<u8>,
@@ -92,6 +107,27 @@ pub fn official_full_header_len(
+ RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH)
}
+pub fn official_encoded_encrypted_header_len(
+ version: u16,
+ pq_enabled: bool,
+) -> Result<usize, RadrootsSimplexSmpCryptoError> {
+ Ok(2 + RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH
+ + RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH
+ + official_large_prefix_len(version)?
+ + official_ratchet_header_len(version, pq_enabled)?)
+}
+
+pub fn official_encoded_encrypted_message_len(
+ version: u16,
+ pq_enabled: bool,
+ padded_body_len: usize,
+) -> Result<usize, RadrootsSimplexSmpCryptoError> {
+ Ok(official_large_prefix_len(version)?
+ + official_encoded_encrypted_header_len(version, pq_enabled)?
+ + RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH
+ + padded_body_len)
+}
+
pub fn official_x448_keypair_from_seed(seed: &[u8]) -> RadrootsSimplexOfficialX448Keypair {
let digest = Sha512::digest(seed);
let mut private_key = [0_u8; RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH];
@@ -237,6 +273,86 @@ pub fn official_aes_gcm_encrypt_padded(
split_official_aes_gcm_payload(&encrypted)
}
+pub fn encode_official_encrypted_header(
+ header: &RadrootsSimplexOfficialEncryptedHeader,
+) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> {
+ validate_official_version(header.version)?;
+ if header.auth_tag.len() != RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH {
+ return Err(RadrootsSimplexSmpCryptoError::InvalidSignatureLength(
+ header.auth_tag.len(),
+ ));
+ }
+ let mut buffer = Vec::with_capacity(
+ 2 + RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH
+ + RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH
+ + official_large_prefix_len(header.version)?
+ + header.body.len(),
+ );
+ buffer.extend_from_slice(&header.version.to_be_bytes());
+ buffer.extend_from_slice(&header.iv);
+ buffer.extend_from_slice(&header.auth_tag);
+ push_official_large_by_version(&mut buffer, header.version, &header.body)?;
+ Ok(buffer)
+}
+
+pub fn decode_official_encrypted_header(
+ bytes: &[u8],
+) -> Result<RadrootsSimplexOfficialEncryptedHeader, RadrootsSimplexSmpCryptoError> {
+ let mut cursor = OfficialCursor::new(bytes);
+ let version = cursor.read_u16()?;
+ validate_official_version(version)?;
+ let iv = cursor.read_array::<RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH>()?;
+ let auth_tag = cursor
+ .read_slice(RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH)?
+ .to_vec();
+ let body = cursor.read_official_large()?.to_vec();
+ cursor.finish()?;
+ Ok(RadrootsSimplexOfficialEncryptedHeader {
+ version,
+ iv,
+ auth_tag,
+ body,
+ })
+}
+
+pub fn encode_official_encrypted_message(
+ version: u16,
+ message: &RadrootsSimplexOfficialEncryptedMessage,
+) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> {
+ validate_official_version(version)?;
+ if message.auth_tag.len() != RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH {
+ return Err(RadrootsSimplexSmpCryptoError::InvalidSignatureLength(
+ message.auth_tag.len(),
+ ));
+ }
+ let mut buffer = Vec::with_capacity(
+ official_large_prefix_len(version)?
+ + message.encrypted_header.len()
+ + RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH
+ + message.body.len(),
+ );
+ push_official_large_by_version(&mut buffer, version, &message.encrypted_header)?;
+ buffer.extend_from_slice(&message.auth_tag);
+ buffer.extend_from_slice(&message.body);
+ Ok(buffer)
+}
+
+pub fn decode_official_encrypted_message(
+ bytes: &[u8],
+) -> Result<RadrootsSimplexOfficialEncryptedMessage, RadrootsSimplexSmpCryptoError> {
+ let mut cursor = OfficialCursor::new(bytes);
+ let encrypted_header = cursor.read_official_large()?.to_vec();
+ let auth_tag = cursor
+ .read_slice(RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH)?
+ .to_vec();
+ let body = cursor.read_remaining().to_vec();
+ Ok(RadrootsSimplexOfficialEncryptedMessage {
+ encrypted_header,
+ auth_tag,
+ body,
+ })
+}
+
pub fn official_aes_gcm_decrypt_padded(
key: &[u8],
iv: &[u8],
@@ -369,6 +485,113 @@ fn official_hkdf3(
))
}
+fn validate_official_version(version: u16) -> Result<(), RadrootsSimplexSmpCryptoError> {
+ if version < RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION
+ || version > RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION
+ {
+ return Err(RadrootsSimplexSmpCryptoError::InvalidOfficialRatchetVersion(version));
+ }
+ Ok(())
+}
+
+fn official_large_prefix_len(version: u16) -> Result<usize, RadrootsSimplexSmpCryptoError> {
+ validate_official_version(version)?;
+ Ok(if version >= RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION {
+ 2
+ } else {
+ 1
+ })
+}
+
+fn push_official_large_by_version(
+ buffer: &mut Vec<u8>,
+ version: u16,
+ value: &[u8],
+) -> Result<(), RadrootsSimplexSmpCryptoError> {
+ if version >= RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION {
+ 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());
+ } else {
+ if value.len() > u8::MAX as usize {
+ return Err(RadrootsSimplexSmpCryptoError::InvalidMessageLength {
+ actual: value.len(),
+ padded: u8::MAX as usize,
+ });
+ }
+ buffer.push(value.len() as u8);
+ }
+ buffer.extend_from_slice(value);
+ Ok(())
+}
+
+struct OfficialCursor<'a> {
+ bytes: &'a [u8],
+ position: usize,
+}
+
+impl<'a> OfficialCursor<'a> {
+ const fn new(bytes: &'a [u8]) -> Self {
+ Self { bytes, position: 0 }
+ }
+
+ fn finish(&self) -> Result<(), RadrootsSimplexSmpCryptoError> {
+ if self.position == self.bytes.len() {
+ Ok(())
+ } else {
+ Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(
+ self.bytes.len() - self.position,
+ ))
+ }
+ }
+
+ fn read_u16(&mut self) -> Result<u16, RadrootsSimplexSmpCryptoError> {
+ let bytes = self.read_slice(2)?;
+ Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
+ }
+
+ fn read_array<const N: usize>(&mut self) -> Result<[u8; N], RadrootsSimplexSmpCryptoError> {
+ let bytes = self.read_slice(N)?;
+ let mut value = [0_u8; N];
+ value.copy_from_slice(bytes);
+ Ok(value)
+ }
+
+ fn read_slice(&mut self, len: usize) -> Result<&'a [u8], RadrootsSimplexSmpCryptoError> {
+ let Some(bytes) = self.bytes.get(self.position..self.position + len) else {
+ return Err(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(
+ self.bytes.len().saturating_sub(self.position),
+ ));
+ };
+ self.position += len;
+ Ok(bytes)
+ }
+
+ fn read_official_large(&mut self) -> Result<&'a [u8], RadrootsSimplexSmpCryptoError> {
+ let first = *self
+ .bytes
+ .get(self.position)
+ .ok_or(RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(0))?;
+ let len = if first < 32 {
+ self.read_u16()? as usize
+ } else {
+ self.position += 1;
+ first as usize
+ };
+ self.read_slice(len)
+ }
+
+ fn read_remaining(&mut self) -> &'a [u8] {
+ let bytes = &self.bytes[self.position..];
+ self.position = self.bytes.len();
+ bytes
+ }
+}
+
fn pq_seed(seed: &[u8]) -> [u8; RADROOTS_SIMPLEX_OFFICIAL_AES_KEY_LENGTH] {
let digest = Sha256::digest(seed);
let mut value = [0_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_KEY_LENGTH];
@@ -387,6 +610,22 @@ mod tests {
assert_eq!(official_ratchet_header_len(3, true).unwrap(), 2_310);
assert_eq!(official_full_header_len(3, false).unwrap(), 123);
assert_eq!(official_full_header_len(3, true).unwrap(), 2_345);
+ assert_eq!(
+ official_encoded_encrypted_header_len(2, false).unwrap(),
+ 123
+ );
+ assert_eq!(
+ official_encoded_encrypted_header_len(3, false).unwrap(),
+ 124
+ );
+ assert_eq!(
+ official_encoded_encrypted_header_len(3, true).unwrap(),
+ 2_346
+ );
+ assert_eq!(
+ official_encoded_encrypted_message_len(3, false, 15_840).unwrap(),
+ 15_982
+ );
}
#[test]
@@ -473,6 +712,64 @@ mod tests {
}
#[test]
+ fn official_encrypted_header_and_message_wire_roundtrip() {
+ let header = RadrootsSimplexOfficialEncryptedHeader {
+ version: RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION,
+ iv: [21_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH],
+ auth_tag: vec![22_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH],
+ body: vec![23_u8; RADROOTS_SIMPLEX_OFFICIAL_RATCHET_HEADER_LENGTH],
+ };
+ let encoded_header = encode_official_encrypted_header(&header).unwrap();
+ assert_eq!(encoded_header.len(), 124);
+ assert_eq!(
+ decode_official_encrypted_header(&encoded_header).unwrap(),
+ header
+ );
+
+ let message = RadrootsSimplexOfficialEncryptedMessage {
+ encrypted_header: encoded_header,
+ auth_tag: vec![24_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH],
+ body: vec![25_u8; 96],
+ };
+ let encoded = encode_official_encrypted_message(
+ RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION,
+ &message,
+ )
+ .unwrap();
+ assert_eq!(encoded.len(), 2 + 124 + 16 + 96);
+ assert_eq!(
+ decode_official_encrypted_message(&encoded).unwrap(),
+ message
+ );
+ }
+
+ #[test]
+ fn official_encrypted_message_rejects_malformed_wire_lengths() {
+ let header = RadrootsSimplexOfficialEncryptedHeader {
+ version: 3,
+ iv: [31_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_IV_LENGTH],
+ auth_tag: vec![32_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH],
+ body: vec![33_u8; RADROOTS_SIMPLEX_OFFICIAL_RATCHET_HEADER_LENGTH],
+ };
+ let mut encoded_header = encode_official_encrypted_header(&header).unwrap();
+ encoded_header.truncate(encoded_header.len() - 1);
+ assert!(matches!(
+ decode_official_encrypted_header(&encoded_header).unwrap_err(),
+ RadrootsSimplexSmpCryptoError::InvalidCiphertextLength(_)
+ ));
+
+ let message = RadrootsSimplexOfficialEncryptedMessage {
+ encrypted_header: encode_official_encrypted_header(&header).unwrap(),
+ auth_tag: vec![34_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_AUTH_TAG_LENGTH - 1],
+ body: vec![35_u8; 32],
+ };
+ assert!(matches!(
+ encode_official_encrypted_message(3, &message).unwrap_err(),
+ RadrootsSimplexSmpCryptoError::InvalidSignatureLength(_)
+ ));
+ }
+
+ #[test]
fn official_kdfs_split_root_and_chain_material() {
let root = official_root_kdf(
&[1_u8; RADROOTS_SIMPLEX_OFFICIAL_AES_KEY_LENGTH],