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:
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(¶ms.key_1)?);
+ let key_2 = encode_official_urlsafe_bytes(&encode_official_x448_public_key_der(¶ms.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(¶ms)?;
+ 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(¶ms).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(¶ms).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) =