lib

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

commit a6d107eff1815c5699b6799c95d5478dbe1927a7
parent e58a3d2efd5d9147dcd28cf6c9fd6cbf5ca85a1d
Author: triesap <tyson@radroots.org>
Date:   Mon, 22 Jun 2026 23:41:00 +0000

simplex: model official X3DH invite params

- add official X3DH URI parameter codecs with DER X448 keys
- carry structured ratchet params in agent connection links
- derive invite queue secrets from the SMP queue DH key
- emit DER-wrapped X25519 queue keys for runtime contact links

Diffstat:
MCargo.lock | 1+
Mcrates/simplex_agent_proto/src/codec.rs | 33++++++++++++++++++++++++++++++---
Mcrates/simplex_agent_proto/src/error.rs | 4++++
Mcrates/simplex_agent_proto/src/lib.rs | 4+++-
Mcrates/simplex_agent_proto/src/model.rs | 6++++--
Mcrates/simplex_agent_runtime/src/runtime.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/simplex_smp_crypto/Cargo.toml | 1+
Mcrates/simplex_smp_crypto/src/error.rs | 4++++
Mcrates/simplex_smp_crypto/src/lib.rs | 20+++++++++++---------
Mcrates/simplex_smp_crypto/src/official_ratchet.rs | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 354 insertions(+), 35 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4636,6 +4636,7 @@ name = "radroots_simplex_smp_crypto" version = "0.1.0-alpha.2" dependencies = [ "aes-gcm", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hkdf", diff --git a/crates/simplex_agent_proto/src/codec.rs b/crates/simplex_agent_proto/src/codec.rs @@ -10,6 +10,9 @@ 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::{ + decode_official_x3dh_params_uri, encode_official_x3dh_params_uri, +}; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpServerAddress, }; @@ -20,7 +23,9 @@ pub fn encode_connection_link( let mut buffer = Vec::new(); push_short_bytes(&mut buffer, link.invitation_queue.to_string().as_bytes())?; push_short_bytes(&mut buffer, &link.connection_id)?; - push_short_bytes(&mut buffer, &link.e2e_public_key)?; + let e2e_ratchet_params = encode_official_x3dh_params_uri(&link.e2e_ratchet_params) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()))?; + push_short_bytes(&mut buffer, e2e_ratchet_params.as_bytes())?; buffer.push(encode_bool(link.contact_address)); Ok(buffer) } @@ -34,7 +39,11 @@ pub fn decode_connection_link( let link = RadrootsSimplexAgentConnectionLink { invitation_queue: RadrootsSimplexSmpQueueUri::parse(&invitation_queue)?, connection_id: cursor.read_short_bytes()?, - e2e_public_key: cursor.read_short_bytes()?, + e2e_ratchet_params: decode_official_x3dh_params_uri( + &String::from_utf8(cursor.read_short_bytes()?) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidUtf8(error.to_string()))?, + ) + .map_err(|error| RadrootsSimplexAgentProtoError::InvalidE2eParameters(error.to_string()))?, contact_address: decode_bool(cursor.read_byte()?)?, }; cursor.finish()?; @@ -696,6 +705,10 @@ impl<'a> Cursor<'a> { #[cfg(test)] mod tests { use super::*; + use radroots_simplex_smp_crypto::prelude::{ + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, + RadrootsSimplexOfficialX3dhParams, official_x448_keypair_from_seed, + }; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpQueueMode, RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpVersionRange, }; @@ -707,12 +720,26 @@ mod tests { .unwrap() } + fn sample_x3dh_params() -> RadrootsSimplexOfficialX3dhParams { + RadrootsSimplexOfficialX3dhParams { + version_range: RadrootsSimplexSmpVersionRange::new( + RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + ) + .unwrap(), + key_1: official_x448_keypair_from_seed(b"rr-synth-agent-link-x3dh-1").public_key, + key_2: official_x448_keypair_from_seed(b"rr-synth-agent-link-x3dh-2").public_key, + pq_public_key: None, + pq_ciphertext: None, + } + } + #[test] fn roundtrips_connection_link() { let link = RadrootsSimplexAgentConnectionLink { invitation_queue: sample_queue_uri(), connection_id: b"conn-1".to_vec(), - e2e_public_key: b"e2e".to_vec(), + e2e_ratchet_params: sample_x3dh_params(), contact_address: true, }; let encoded = encode_connection_link(&link).unwrap(); diff --git a/crates/simplex_agent_proto/src/error.rs b/crates/simplex_agent_proto/src/error.rs @@ -12,6 +12,7 @@ pub enum RadrootsSimplexAgentProtoError { InvalidLargeFieldLength(usize), InvalidBoolEncoding(u8), InvalidRatchetHeader(String), + InvalidE2eParameters(String), TrailingBytes, } @@ -40,6 +41,9 @@ impl fmt::Display for RadrootsSimplexAgentProtoError { Self::InvalidRatchetHeader(error) => { write!(f, "invalid SimpleX agent ratchet header: {error}") } + Self::InvalidE2eParameters(error) => { + write!(f, "invalid SimpleX agent E2E parameters: {error}") + } Self::TrailingBytes => write!(f, "trailing bytes after SimpleX agent decode"), } } diff --git a/crates/simplex_agent_proto/src/lib.rs b/crates/simplex_agent_proto/src/lib.rs @@ -25,6 +25,8 @@ pub mod prelude { RadrootsSimplexAgentQueueUseDecision, }; pub use radroots_simplex_smp_crypto::prelude::{ - RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetState, + RadrootsSimplexOfficialX3dhParams, RadrootsSimplexSmpRatchetHeader, + RadrootsSimplexSmpRatchetState, decode_official_x3dh_params_uri, + encode_official_x3dh_params_uri, official_x448_keypair_from_seed, }; } diff --git a/crates/simplex_agent_proto/src/model.rs b/crates/simplex_agent_proto/src/model.rs @@ -1,5 +1,7 @@ use alloc::vec::Vec; -use radroots_simplex_smp_crypto::prelude::RadrootsSimplexSmpRatchetHeader; +use radroots_simplex_smp_crypto::prelude::{ + RadrootsSimplexOfficialX3dhParams, RadrootsSimplexSmpRatchetHeader, +}; use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpServerAddress, RadrootsSimplexSmpVersionRange, }; @@ -30,7 +32,7 @@ pub enum RadrootsSimplexAgentConnectionStatus { pub struct RadrootsSimplexAgentConnectionLink { pub invitation_queue: RadrootsSimplexSmpQueueUri, pub connection_id: Vec<u8>, - pub e2e_public_key: Vec<u8>, + pub e2e_ratchet_params: RadrootsSimplexOfficialX3dhParams, pub contact_address: bool, } diff --git a/crates/simplex_agent_runtime/src/runtime.rs b/crates/simplex_agent_runtime/src/runtime.rs @@ -5,7 +5,7 @@ use alloc::format; use alloc::string::String; use alloc::vec::Vec; use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; use radroots_simplex_agent_proto::prelude::{ RadrootsSimplexAgentConnectionLink, RadrootsSimplexAgentConnectionMode, RadrootsSimplexAgentConnectionStatus, RadrootsSimplexAgentDecryptedMessage, @@ -21,7 +21,9 @@ use radroots_simplex_agent_store::prelude::{ RadrootsSimplexAgentStore, }; use radroots_simplex_smp_crypto::prelude::{ - RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RadrootsSimplexSmpCommandAuthorization, + 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, @@ -32,7 +34,7 @@ use radroots_simplex_smp_proto::prelude::{ RadrootsSimplexSmpMessageFlags, RadrootsSimplexSmpNewQueueRequest, RadrootsSimplexSmpQueueIdsResponse, RadrootsSimplexSmpQueueMode, RadrootsSimplexSmpQueueRequestData, RadrootsSimplexSmpQueueUri, RadrootsSimplexSmpSendCommand, - RadrootsSimplexSmpSubscriptionMode, + RadrootsSimplexSmpSubscriptionMode, RadrootsSimplexSmpVersionRange, }; use radroots_simplex_smp_transport::prelude::{ RadrootsSimplexSmpCommandTransport, RadrootsSimplexSmpSubscriptionReceiveRequest, @@ -161,23 +163,42 @@ impl RadrootsSimplexAgentRuntime { now: u64, ) -> Result<String, RadrootsSimplexAgentRuntimeError> { let e2e_keypair = RadrootsSimplexSmpX25519Keypair::from_seed(&e2e_seed); - invitation_queue.recipient_dh_public_key = encode_queue_public_key(&e2e_keypair.public_key); + invitation_queue.recipient_dh_public_key = encode_queue_public_key(&e2e_keypair.public_key) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; invitation_queue.sender_id = placeholder_sender_id( invitation_queue.server.server_identity.as_bytes(), &now.to_be_bytes(), ); - let local_dh_public_key = official_x448_keypair_from_seed(&derive_material( - b"connection-create-local-dh", + let x3dh_key_1 = official_x448_keypair_from_seed(&derive_material( + b"connection-create-x3dh-1", &[ invitation_queue.to_string().as_bytes(), &e2e_keypair.public_key, &now.to_be_bytes(), ], - )) - .public_key; + )); + let x3dh_key_2 = official_x448_keypair_from_seed(&derive_material( + b"connection-create-x3dh-2", + &[ + invitation_queue.to_string().as_bytes(), + &e2e_keypair.public_key, + &now.to_be_bytes(), + ], + )); + let e2e_ratchet_params = 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: x3dh_key_1.public_key.clone(), + key_2: x3dh_key_2.public_key.clone(), + pq_public_key: None, + pq_ciphertext: None, + }; let ratchet_state = RadrootsSimplexSmpRatchetState::initiator( - local_dh_public_key, - invitation_queue.recipient_dh_public_key.as_bytes().to_vec(), + x3dh_key_2.public_key, + x3dh_key_1.public_key, None, ) .ok(); @@ -194,7 +215,7 @@ impl RadrootsSimplexAgentRuntime { let invitation = RadrootsSimplexAgentConnectionLink { invitation_queue: invitation_queue.clone(), connection_id: connection.id.as_bytes().to_vec(), - e2e_public_key: e2e_keypair.public_key.clone(), + e2e_ratchet_params, contact_address, }; self.store.connection_mut(&connection.id)?.invitation = Some(invitation); @@ -246,11 +267,14 @@ impl RadrootsSimplexAgentRuntime { ) -> Result<String, RadrootsSimplexAgentRuntimeError> { let local_e2e_keypair = RadrootsSimplexSmpX25519Keypair::generate() .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; + let invitation_e2e_public_key = + decode_queue_public_key(&invitation.invitation_queue.recipient_dh_public_key)?; let shared_secret = - derive_shared_secret(&local_e2e_keypair.private_key, &invitation.e2e_public_key) + derive_shared_secret(&local_e2e_keypair.private_key, &invitation_e2e_public_key) .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string()))?; reply_queue.recipient_dh_public_key = - encode_queue_public_key(&local_e2e_keypair.public_key); + encode_queue_public_key(&local_e2e_keypair.public_key) + .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( @@ -264,11 +288,7 @@ impl RadrootsSimplexAgentRuntime { .public_key; let ratchet_state = RadrootsSimplexSmpRatchetState::responder( local_dh_public_key, - invitation - .invitation_queue - .recipient_dh_public_key - .as_bytes() - .to_vec(), + invitation.e2e_ratchet_params.key_2.clone(), None, ) .ok(); @@ -1703,8 +1723,21 @@ fn correlation_id_for_command(command_id: u64) -> RadrootsSimplexSmpCorrelationI RadrootsSimplexSmpCorrelationId::new(correlation) } -fn encode_queue_public_key(public_key: &[u8]) -> String { - URL_SAFE_NO_PAD.encode(public_key) +fn encode_queue_public_key(public_key: &[u8]) -> Result<String, RadrootsSimplexSmpCryptoError> { + Ok(URL_SAFE.encode(encode_x25519_public_key_x509(public_key)?)) +} + +fn decode_queue_public_key(encoded: &str) -> Result<Vec<u8>, RadrootsSimplexAgentRuntimeError> { + let bytes = URL_SAFE + .decode(encoded.as_bytes()) + .or_else(|_| URL_SAFE_NO_PAD.decode(encoded.as_bytes())) + .map_err(|error| { + RadrootsSimplexAgentRuntimeError::Runtime(format!( + "failed to decode SimpleX queue E2E public key: {error}" + )) + })?; + decode_x25519_public_key_x509(&bytes) + .map_err(|error| RadrootsSimplexAgentRuntimeError::Runtime(error.to_string())) } fn placeholder_sender_id(seed_a: &[u8], seed_b: &[u8]) -> String { diff --git a/crates/simplex_smp_crypto/Cargo.toml b/crates/simplex_smp_crypto/Cargo.toml @@ -28,6 +28,7 @@ aes-gcm = { workspace = true, default-features = false, features = [ "aes", "alloc", ] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } ed25519-dalek = { workspace = true, default-features = false, features = [ "alloc", ] } diff --git a/crates/simplex_smp_crypto/src/error.rs b/crates/simplex_smp_crypto/src/error.rs @@ -24,6 +24,7 @@ pub enum RadrootsSimplexSmpCryptoError { AesGcmAuthenticationFailed, InvalidOfficialRatchetVersion(u16), InvalidOfficialRatchetPadding, + InvalidOfficialX3dhParameters(String), InvalidPqKeyLength(usize), InvalidPqCiphertextLength(usize), } @@ -102,6 +103,9 @@ impl fmt::Display for RadrootsSimplexSmpCryptoError { Self::InvalidOfficialRatchetPadding => { write!(f, "invalid official SMP ratchet padding") } + Self::InvalidOfficialX3dhParameters(error) => { + write!(f, "invalid official SMP X3DH parameters: {error}") + } Self::InvalidPqKeyLength(length) => { write!(f, "invalid SMP PQ key length {length}") } diff --git a/crates/simplex_smp_crypto/src/lib.rs b/crates/simplex_smp_crypto/src/lib.rs @@ -40,17 +40,19 @@ pub mod prelude { RadrootsSimplexOfficialChainKdfOutput, RadrootsSimplexOfficialEncryptedHeader, RadrootsSimplexOfficialEncryptedMessage, RadrootsSimplexOfficialMsgHeader, RadrootsSimplexOfficialRootKdfOutput, RadrootsSimplexOfficialSntrup761Keypair, - RadrootsSimplexOfficialX448Keypair, decapsulate_official_sntrup761, - decode_official_encrypted_header, decode_official_encrypted_message, - decode_official_msg_header, decode_official_x448_public_key_der, + RadrootsSimplexOfficialX3dhParams, RadrootsSimplexOfficialX448Keypair, + decapsulate_official_sntrup761, decode_official_encrypted_header, + decode_official_encrypted_message, decode_official_msg_header, + decode_official_x3dh_params_uri, decode_official_x448_public_key_der, derive_official_x448_shared_secret, encapsulate_official_sntrup761, encode_official_encrypted_header, encode_official_encrypted_message, - encode_official_msg_header, encode_official_x448_public_key_der, - generate_official_sntrup761_keypair, generate_official_x448_keypair, - official_aes_gcm_decrypt_padded, 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, + encode_official_msg_header, encode_official_x3dh_params_uri, + encode_official_x448_public_key_der, generate_official_sntrup761_keypair, + generate_official_x448_keypair, official_aes_gcm_decrypt_padded, + 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, }; pub use crate::ratchet::{ RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetRole, diff --git a/crates/simplex_smp_crypto/src/official_ratchet.rs b/crates/simplex_smp_crypto/src/official_ratchet.rs @@ -2,8 +2,13 @@ use crate::error::RadrootsSimplexSmpCryptoError; use aes_gcm::aead::consts::U16; use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::{AesGcm, Nonce, aes::Aes256}; +use alloc::format; +use alloc::string::String; use alloc::vec::Vec; +use base64::Engine as _; +use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; use hkdf::Hkdf; +use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpVersionRange; use sha2::{Digest, Sha256, Sha512}; pub const RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION: u16 = 2; @@ -68,6 +73,15 @@ pub struct RadrootsSimplexOfficialEncryptedMessage { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexOfficialX3dhParams { + pub version_range: RadrootsSimplexSmpVersionRange, + pub key_1: Vec<u8>, + pub key_2: Vec<u8>, + pub pq_public_key: Option<Vec<u8>>, + pub pq_ciphertext: Option<Vec<u8>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsSimplexOfficialMsgHeader { pub max_version: u16, pub dh_public_key: Vec<u8>, @@ -201,6 +215,113 @@ pub fn decode_official_x448_public_key_der( Ok(encoded[RADROOTS_SIMPLEX_OFFICIAL_X448_DER_PUBLIC_KEY_PREFIX.len()..].to_vec()) } +pub fn encode_official_x3dh_params_uri( + params: &RadrootsSimplexOfficialX3dhParams, +) -> Result<String, RadrootsSimplexSmpCryptoError> { + validate_official_x3dh_params(params)?; + let key_1 = encode_official_urlsafe_bytes(&encode_official_x448_public_key_der(&params.key_1)?); + let key_2 = encode_official_urlsafe_bytes(&encode_official_x448_public_key_der(&params.key_2)?); + let mut encoded = format!("v={}&x3dh={key_1},{key_2}", params.version_range); + if let Some(pq_public_key) = params.pq_public_key.as_deref() { + encoded.push_str("&kem_key="); + encoded.push_str(&encode_official_urlsafe_bytes(pq_public_key)); + } + if let Some(pq_ciphertext) = params.pq_ciphertext.as_deref() { + encoded.push_str("&kem_ct="); + encoded.push_str(&encode_official_urlsafe_bytes(pq_ciphertext)); + } + Ok(encoded) +} + +pub fn decode_official_x3dh_params_uri( + encoded: &str, +) -> Result<RadrootsSimplexOfficialX3dhParams, RadrootsSimplexSmpCryptoError> { + let mut version_range = None; + let mut x3dh = None; + let mut pq_public_key = None; + let mut pq_ciphertext = None; + for pair in encoded.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').ok_or_else(|| { + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "field is missing `=`".to_owned(), + ) + })?; + match key { + "v" => { + if version_range.replace(value.parse()?).is_some() { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "duplicate `v` field".to_owned(), + ), + ); + } + } + "x3dh" => { + if x3dh.replace(value.to_owned()).is_some() { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "duplicate `x3dh` field".to_owned(), + ), + ); + } + } + "kem_key" => { + if pq_public_key + .replace(decode_official_urlsafe_bytes(value)?) + .is_some() + { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "duplicate `kem_key` field".to_owned(), + ), + ); + } + } + "kem_ct" => { + if pq_ciphertext + .replace(decode_official_urlsafe_bytes(value)?) + .is_some() + { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "duplicate `kem_ct` field".to_owned(), + ), + ); + } + } + _ => { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "unknown field".to_owned(), + ), + ); + } + } + } + let x3dh = x3dh.ok_or_else(|| { + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "missing `x3dh` field".to_owned(), + ) + })?; + let keys = split_official_x3dh_keys(&x3dh)?; + let params = RadrootsSimplexOfficialX3dhParams { + version_range: version_range.ok_or_else(|| { + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "missing `v` field".to_owned(), + ) + })?, + key_1: decode_official_x448_public_key_der(&decode_official_urlsafe_bytes(keys.0)?)?, + key_2: decode_official_x448_public_key_der(&decode_official_urlsafe_bytes(keys.1)?)?, + pq_public_key, + pq_ciphertext, + }; + validate_official_x3dh_params(&params)?; + Ok(params) +} + pub fn official_sntrup761_keypair_from_seed( seed: &[u8], ) -> RadrootsSimplexOfficialSntrup761Keypair { @@ -592,6 +713,85 @@ fn validate_official_version(version: u16) -> Result<(), RadrootsSimplexSmpCrypt Ok(()) } +fn validate_official_version_range( + range: RadrootsSimplexSmpVersionRange, +) -> Result<(), RadrootsSimplexSmpCryptoError> { + validate_official_version(range.min)?; + validate_official_version(range.max) +} + +fn validate_official_x3dh_params( + params: &RadrootsSimplexOfficialX3dhParams, +) -> Result<(), RadrootsSimplexSmpCryptoError> { + validate_official_version_range(params.version_range)?; + if params.key_1.len() != RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength( + params.key_1.len(), + )); + } + if params.key_2.len() != RADROOTS_SIMPLEX_OFFICIAL_X448_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPublicKeyLength( + params.key_2.len(), + )); + } + if params.pq_ciphertext.is_some() && params.pq_public_key.is_none() { + return Err(RadrootsSimplexSmpCryptoError::IncompletePqHeader); + } + if let Some(pq_public_key) = params.pq_public_key.as_deref() { + if params.version_range.max < RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "PQ key requires E2E version 3".to_owned(), + ), + ); + } + if pq_public_key.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_PUBLIC_KEY_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPqKeyLength( + pq_public_key.len(), + )); + } + } + if let Some(pq_ciphertext) = params.pq_ciphertext.as_deref() { + if pq_ciphertext.len() != RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_CIPHERTEXT_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidPqCiphertextLength( + pq_ciphertext.len(), + )); + } + } + Ok(()) +} + +fn encode_official_urlsafe_bytes(bytes: &[u8]) -> String { + URL_SAFE.encode(bytes) +} + +fn decode_official_urlsafe_bytes(value: &str) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + URL_SAFE + .decode(value.as_bytes()) + .or_else(|_| URL_SAFE_NO_PAD.decode(value.as_bytes())) + .map_err(|_| { + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "invalid base64url field".to_owned(), + ) + }) +} + +fn split_official_x3dh_keys(value: &str) -> Result<(&str, &str), RadrootsSimplexSmpCryptoError> { + let (key_1, rest) = value.split_once(',').ok_or_else(|| { + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "`x3dh` field must contain two keys".to_owned(), + ) + })?; + if rest.contains(',') { + return Err( + RadrootsSimplexSmpCryptoError::InvalidOfficialX3dhParameters( + "`x3dh` field must contain two keys".to_owned(), + ), + ); + } + Ok((key_1, rest)) +} + fn official_large_prefix_len(version: u16) -> Result<usize, RadrootsSimplexSmpCryptoError> { validate_official_version(version)?; Ok(if version >= RADROOTS_SIMPLEX_OFFICIAL_E2E_PQ_VERSION { @@ -817,6 +1017,49 @@ mod tests { } #[test] + fn official_x3dh_params_uri_roundtrips() { + let keypair_1 = official_x448_keypair_from_seed(b"rr-synth-official-x3dh-1"); + let keypair_2 = official_x448_keypair_from_seed(b"rr-synth-official-x3dh-2"); + let params = RadrootsSimplexOfficialX3dhParams { + version_range: RadrootsSimplexSmpVersionRange::new( + RADROOTS_SIMPLEX_OFFICIAL_E2E_KDF_VERSION, + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + ) + .unwrap(), + key_1: keypair_1.public_key, + key_2: keypair_2.public_key, + pq_public_key: None, + pq_ciphertext: None, + }; + let encoded = encode_official_x3dh_params_uri(&params).unwrap(); + assert!(encoded.starts_with("v=2-3&x3dh=MEIwBQYDK2VvAzkA")); + assert!(encoded.contains(',')); + assert_eq!(decode_official_x3dh_params_uri(&encoded).unwrap(), params); + } + + #[test] + fn official_x3dh_params_rejects_incomplete_pq_fields() { + let keypair_1 = official_x448_keypair_from_seed(b"rr-synth-official-x3dh-pq-1"); + let keypair_2 = official_x448_keypair_from_seed(b"rr-synth-official-x3dh-pq-2"); + let params = RadrootsSimplexOfficialX3dhParams { + version_range: RadrootsSimplexSmpVersionRange::single( + RADROOTS_SIMPLEX_OFFICIAL_E2E_CURRENT_VERSION, + ), + key_1: keypair_1.public_key, + key_2: keypair_2.public_key, + pq_public_key: None, + pq_ciphertext: Some(vec![ + 0_u8; + RADROOTS_SIMPLEX_OFFICIAL_SNTRUP761_CIPHERTEXT_LENGTH + ]), + }; + assert_eq!( + encode_official_x3dh_params_uri(&params).unwrap_err(), + RadrootsSimplexSmpCryptoError::IncompletePqHeader + ); + } + + #[test] fn sntrup761_encapsulation_roundtrips() { let recipient = official_sntrup761_keypair_from_seed(b"rr-synth-official-pq-recipient"); let (ciphertext, sender_secret) =