From 3d6ac7dcf573d95f93f834ad3151f8355baa2574 Mon Sep 17 00:00:00 2001 From: Patryk Osmaczko <33099791+osmaczko@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:51:57 +0100 Subject: [PATCH] feat: update introduction bundle encoding closes: #27 --- Cargo.lock | 4 +- conversations/Cargo.toml | 1 + conversations/src/context.rs | 3 +- conversations/src/inbox/handler.rs | 26 ++-- conversations/src/inbox/handshake.rs | 2 +- conversations/src/inbox/introduction.rs | 175 +++++++++++++++++++----- crypto/Cargo.toml | 1 + crypto/src/lib.rs | 2 + crypto/src/x3dh.rs | 7 +- crypto/src/xeddsa_sign.rs | 129 +++++++++++++++++ 10 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 crypto/src/xeddsa_sign.rs diff --git a/Cargo.lock b/Cargo.lock index 36d18bc..8f52885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ dependencies = [ [[package]] name = "chat-proto" version = "0.1.0" -source = "git+https://github.com/logos-messaging/chat_proto#21cd52b0649c959f43eec19e1edad12451ccc382" +source = "git+https://github.com/logos-messaging/chat_proto#44d5360c41d721a011d20ee69a75a85357b33b0e" dependencies = [ "prost", ] @@ -137,6 +137,7 @@ dependencies = [ "hkdf", "rand_core", "sha2", + "thiserror", "x25519-dalek", "xeddsa", "zeroize", @@ -503,6 +504,7 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" name = "logos-chat" version = "0.1.0" dependencies = [ + "base64ct", "blake2", "chat-proto", "crypto", diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index 2355137..4614cf4 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" crate-type = ["staticlib","dylib"] [dependencies] +base64ct = { version = "1.6", features = ["alloc"] } blake2.workspace = true chat-proto = { git = "https://github.com/logos-messaging/chat_proto" } crypto = { path = "../crypto" } diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 6445ef6..d5fb8bd 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -107,8 +107,7 @@ impl Context { } pub fn create_intro_bundle(&mut self) -> Result, ChatError> { - let pkb = self.inbox.create_bundle(); - Ok(Introduction::from(pkb).into()) + Ok(self.inbox.create_intro_bundle().into()) } fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { diff --git a/conversations/src/inbox/handler.rs b/conversations/src/inbox/handler.rs index ddfc208..402806f 100644 --- a/conversations/src/inbox/handler.rs +++ b/conversations/src/inbox/handler.rs @@ -16,6 +16,8 @@ use crate::inbox::handshake::InboxHandshake; use crate::proto; use crate::types::{AddressedEncryptedPayload, ContentData}; +use super::introduction::sign_intro_binding; + /// Compute the deterministic Delivery_address for an installation fn delivery_address_for_installation(_: PublicKey) -> String { // TODO: Implement Delivery Address @@ -51,18 +53,19 @@ impl Inbox { } } - pub fn create_bundle(&mut self) -> PrekeyBundle { + pub fn create_intro_bundle(&mut self) -> Introduction { let ephemeral = StaticSecret::random(); - let signed_prekey = PublicKey::from(&ephemeral); + let ephemeral_key = PublicKey::from(&ephemeral); self.ephemeral_keys - .insert(hex::encode(signed_prekey.as_bytes()), ephemeral); + .insert(hex::encode(ephemeral_key.as_bytes()), ephemeral); - PrekeyBundle { - identity_key: self.ident.public_key(), - signed_prekey, - signature: [0u8; 64], - onetime_prekey: None, + let signature = sign_intro_binding(self.ident.secret(), &ephemeral_key, OsRng); + + Introduction { + installation_key: self.ident.public_key(), + ephemeral_key, + signature, } } @@ -73,11 +76,10 @@ impl Inbox { ) -> Result<(PrivateV1Convo, Vec), ChatError> { let mut rng = OsRng; - // TODO: Include signature in introduction bundle. Manaully fill for now let pkb = PrekeyBundle { identity_key: remote_bundle.installation_key, signed_prekey: remote_bundle.ephemeral_key, - signature: [0u8; 64], + signature: remote_bundle.signature, onetime_prekey: None, }; @@ -244,9 +246,9 @@ mod tests { let raya_ident = Identity::new(); let mut raya_inbox = Inbox::new(raya_ident.into()); - let bundle = raya_inbox.create_bundle(); + let bundle = raya_inbox.create_intro_bundle(); let (_, mut payloads) = saro_inbox - .invite_to_private_convo(&bundle.into(), "hello".as_bytes()) + .invite_to_private_convo(&bundle, "hello".as_bytes()) .unwrap(); let payload = payloads.remove(0); diff --git a/conversations/src/inbox/handshake.rs b/conversations/src/inbox/handshake.rs index 7a2f0c2..eda7d00 100644 --- a/conversations/src/inbox/handshake.rs +++ b/conversations/src/inbox/handshake.rs @@ -97,7 +97,7 @@ mod tests { let bob_bundle = PrekeyBundle { identity_key: PublicKey::from(&bob_identity), signed_prekey: bob_signed_prekey_pub, - signature: [0u8; 64], + signature: crypto::Ed25519Signature([0u8; 64]), onetime_prekey: None, }; diff --git a/conversations/src/inbox/introduction.rs b/conversations/src/inbox/introduction.rs index 1b6ed72..243f795 100644 --- a/conversations/src/inbox/introduction.rs +++ b/conversations/src/inbox/introduction.rs @@ -1,33 +1,63 @@ -use crypto::PrekeyBundle; -use x25519_dalek::PublicKey; +use base64ct::{Base64UrlUnpadded, Encoding}; +use chat_proto::logoschat::intro::IntroBundle; +use crypto::Ed25519Signature; +use prost::Message; +use rand_core::{CryptoRng, RngCore}; +use x25519_dalek::{PublicKey, StaticSecret}; use crate::errors::ChatError; +const BUNDLE_PREFIX: &str = "logos_chatintro_1_"; + +fn intro_binding_message(ephemeral: &PublicKey) -> Vec { + let mut message = Vec::with_capacity(BUNDLE_PREFIX.len() + 32); + message.extend_from_slice(BUNDLE_PREFIX.as_bytes()); + message.extend_from_slice(ephemeral.as_bytes()); + message +} + +pub(crate) fn sign_intro_binding( + secret: &StaticSecret, + ephemeral: &PublicKey, + rng: R, +) -> Ed25519Signature { + let message = intro_binding_message(ephemeral); + crypto::xeddsa_sign(secret, &message, rng) +} + +pub(crate) fn verify_intro_binding( + pubkey: &PublicKey, + ephemeral: &PublicKey, + signature: &Ed25519Signature, +) -> Result<(), crypto::SignatureError> { + let message = intro_binding_message(ephemeral); + crypto::xeddsa_verify(pubkey, &message, signature) +} + /// Supplies remote participants with the required keys to use Inbox protocol pub struct Introduction { pub installation_key: PublicKey, pub ephemeral_key: PublicKey, -} - -impl From for Introduction { - fn from(value: PrekeyBundle) -> Self { - Introduction { - installation_key: value.identity_key, - ephemeral_key: value.signed_prekey, - } - } + pub signature: Ed25519Signature, } impl From for Vec { - fn from(val: Introduction) -> Self { - // TODO: avoid copies, via writing directly to slice - let link = format!( - "Bundle:{}:{}", - hex::encode(val.installation_key.as_bytes()), - hex::encode(val.ephemeral_key.as_bytes()), - ); + fn from(intro: Introduction) -> Vec { + let bundle = IntroBundle { + installation_pubkey: prost::bytes::Bytes::copy_from_slice( + intro.installation_key.as_bytes(), + ), + ephemeral_pubkey: prost::bytes::Bytes::copy_from_slice(intro.ephemeral_key.as_bytes()), + signature: prost::bytes::Bytes::copy_from_slice(intro.signature.as_ref()), + }; - link.into_bytes() + let base64_encoded = Base64UrlUnpadded::encode_string(&bundle.encode_to_vec()); + + let mut result = String::with_capacity(BUNDLE_PREFIX.len() + base64_encoded.len()); + result.push_str(BUNDLE_PREFIX); + result.push_str(&base64_encoded); + + result.into_bytes() } } @@ -35,30 +65,109 @@ impl TryFrom<&[u8]> for Introduction { type Error = ChatError; fn try_from(value: &[u8]) -> Result { - let str_value = String::from_utf8_lossy(value); - let parts: Vec<&str> = str_value.splitn(3, ':').collect(); + let str_value = std::str::from_utf8(value) + .map_err(|_| ChatError::BadBundleValue("invalid UTF-8".into()))?; - if parts[0] != "Bundle" { - return Err(ChatError::BadBundleValue( - "not recognized as an introduction bundle".into(), - )); - } + let base64_part = str_value.strip_prefix(BUNDLE_PREFIX).ok_or_else(|| { + ChatError::BadBundleValue("not recognized as an introduction bundle".into()) + })?; - let installation_bytes: [u8; 32] = hex::decode(parts[1]) - .map_err(|_| ChatError::BadParsing("installation_key"))? + let proto_bytes = Base64UrlUnpadded::decode_vec(base64_part) + .map_err(|_| ChatError::BadBundleValue("invalid base64".into()))?; + + let bundle = IntroBundle::decode(proto_bytes.as_slice()) + .map_err(|_| ChatError::BadBundleValue("invalid protobuf".into()))?; + + let installation_bytes: [u8; 32] = bundle + .installation_pubkey + .as_ref() .try_into() .map_err(|_| ChatError::InvalidKeyLength)?; + + let ephemeral_bytes: [u8; 32] = bundle + .ephemeral_pubkey + .as_ref() + .try_into() + .map_err(|_| ChatError::InvalidKeyLength)?; + + let signature_bytes: [u8; 64] = bundle + .signature + .as_ref() + .try_into() + .map_err(|_| ChatError::BadBundleValue("invalid signature length".into()))?; + let installation_key = PublicKey::from(installation_bytes); - - let ephemeral_bytes: [u8; 32] = hex::decode(parts[2]) - .map_err(|_| ChatError::BadParsing("ephemeral_key"))? - .try_into() - .map_err(|_| ChatError::InvalidKeyLength)?; let ephemeral_key = PublicKey::from(ephemeral_bytes); + let signature = Ed25519Signature(signature_bytes); + + verify_intro_binding(&installation_key, &ephemeral_key, &signature) + .map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?; Ok(Introduction { installation_key, ephemeral_key, + signature, }) } } + +#[cfg(test)] +mod tests { + use super::*; + use rand_core::OsRng; + + fn create_test_introduction() -> Introduction { + let install_secret = StaticSecret::random_from_rng(OsRng); + let install_pub = PublicKey::from(&install_secret); + + let ephemeral_secret = StaticSecret::random_from_rng(OsRng); + let ephemeral_pub = PublicKey::from(&ephemeral_secret); + + let signature = sign_intro_binding(&install_secret, &ephemeral_pub, OsRng); + + Introduction { + installation_key: install_pub, + ephemeral_key: ephemeral_pub, + signature, + } + } + + #[test] + fn test_serialization_roundtrip() { + let intro = create_test_introduction(); + let original_install = intro.installation_key; + let original_ephemeral = intro.ephemeral_key; + let original_signature = intro.signature; + + let encoded: Vec = intro.into(); + let decoded = Introduction::try_from(encoded.as_slice()).unwrap(); + + assert_eq!(decoded.installation_key, original_install); + assert_eq!(decoded.ephemeral_key, original_ephemeral); + assert_eq!(decoded.signature, original_signature); + } + + #[test] + fn test_invalid_prefix_rejected() { + assert!(Introduction::try_from(b"wrong_prefix_AAAA".as_slice()).is_err()); + } + + #[test] + fn test_invalid_base64_rejected() { + assert!(Introduction::try_from(b"logos_chatintro_1_!!!invalid!!!".as_slice()).is_err()); + } + + #[test] + fn test_truncated_payload_rejected() { + let intro = create_test_introduction(); + let encoded: Vec = intro.into(); + let encoded_str = String::from_utf8(encoded).unwrap(); + + let truncated = format!( + "logos_chatintro_1_{}", + &encoded_str[BUNDLE_PREFIX.len()..][..10] + ); + + assert!(Introduction::try_from(truncated.as_bytes()).is_err()); + } +} diff --git a/crypto/Cargo.toml b/crypto/Cargo.toml index 9437f82..2c688f3 100644 --- a/crypto/Cargo.toml +++ b/crypto/Cargo.toml @@ -12,3 +12,4 @@ ed25519-dalek = "2.2.0" xeddsa = "1.0.2" zeroize = {version = "1.8.2", features= ["derive"]} generic-array = "1.3.5" +thiserror = "2" diff --git a/crypto/src/lib.rs b/crypto/src/lib.rs index c543994..3b8776b 100644 --- a/crypto/src/lib.rs +++ b/crypto/src/lib.rs @@ -1,5 +1,7 @@ mod keys; mod x3dh; +mod xeddsa_sign; pub use keys::{GenericArray, SecretKey}; pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake}; +pub use xeddsa_sign::{Ed25519Signature, SignatureError, xeddsa_sign, xeddsa_verify}; diff --git a/crypto/src/x3dh.rs b/crypto/src/x3dh.rs index e361df7..48a7bdb 100644 --- a/crypto/src/x3dh.rs +++ b/crypto/src/x3dh.rs @@ -6,13 +6,14 @@ use sha2::Sha256; use x25519_dalek::{PublicKey, SharedSecret, StaticSecret}; use crate::keys::SecretKey; +use crate::xeddsa_sign::Ed25519Signature; /// A prekey bundle containing the public keys needed to initiate an X3DH key exchange. #[derive(Clone, Debug)] pub struct PrekeyBundle { pub identity_key: PublicKey, pub signed_prekey: PublicKey, - pub signature: [u8; 64], + pub signature: Ed25519Signature, pub onetime_prekey: Option, } @@ -151,7 +152,7 @@ mod tests { let bob_bundle = PrekeyBundle { identity_key: bob_identity_pub, signed_prekey: bob_signed_prekey_pub, - signature: [0u8; 64], // Placeholder for signature + signature: Ed25519Signature([0u8; 64]), // Placeholder for signature onetime_prekey: Some(bob_onetime_prekey_pub), }; @@ -191,7 +192,7 @@ mod tests { let bob_bundle = PrekeyBundle { identity_key: bob_identity_pub, signed_prekey: bob_signed_prekey_pub, - signature: [0u8; 64], // Placeholder for signature + signature: Ed25519Signature([0u8; 64]), // Placeholder for signature onetime_prekey: None, }; diff --git a/crypto/src/xeddsa_sign.rs b/crypto/src/xeddsa_sign.rs new file mode 100644 index 0000000..f716fe1 --- /dev/null +++ b/crypto/src/xeddsa_sign.rs @@ -0,0 +1,129 @@ +//! XEdDSA signing using X25519 keys. +//! +//! This module provides generic XEdDSA sign and verify functions +//! that allow signing arbitrary messages with X25519 keys. + +use rand_core::{CryptoRng, RngCore}; +use x25519_dalek::{PublicKey, StaticSecret}; +use xeddsa::{Sign, Verify, xed25519}; + +/// A 64-byte XEdDSA signature over an Ed25519-compatible curve. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Ed25519Signature(pub [u8; 64]); + +impl AsRef<[u8; 64]> for Ed25519Signature { + fn as_ref(&self) -> &[u8; 64] { + &self.0 + } +} + +impl From<[u8; 64]> for Ed25519Signature { + fn from(bytes: [u8; 64]) -> Self { + Self(bytes) + } +} + +/// Error type for signature verification failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("signature verification failed")] +pub struct SignatureError; + +/// Sign a message using XEdDSA with an X25519 secret key. +/// +/// # Arguments +/// * `secret` - The X25519 secret key to sign with +/// * `message` - The message to sign +/// * `rng` - A cryptographically secure random number generator +/// +/// # Returns +/// An `Ed25519Signature` +pub fn xeddsa_sign( + secret: &StaticSecret, + message: &[u8], + mut rng: R, +) -> Ed25519Signature { + let signing_key = xed25519::PrivateKey::from(secret); + Ed25519Signature(signing_key.sign(message, &mut rng)) +} + +/// Verify an XEdDSA signature using an X25519 public key. +/// +/// # Arguments +/// * `pubkey` - The X25519 public key to verify with +/// * `message` - The message that was signed +/// * `signature` - The 64-byte XEdDSA signature to verify +/// +/// # Returns +/// `Ok(())` if the signature is valid, `Err(SignatureError)` otherwise +pub fn xeddsa_verify( + pubkey: &PublicKey, + message: &[u8], + signature: &Ed25519Signature, +) -> Result<(), SignatureError> { + let verify_key = xed25519::PublicKey::from(pubkey); + verify_key + .verify(message, &signature.0) + .map_err(|_| SignatureError) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand_core::OsRng; + + #[test] + fn test_sign_and_verify_roundtrip() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let message = b"test message"; + + let signature = xeddsa_sign(&secret, message, OsRng); + + assert!(xeddsa_verify(&public, message, &signature).is_ok()); + } + + #[test] + fn test_wrong_key_fails() { + let secret = StaticSecret::random_from_rng(OsRng); + let message = b"test message"; + + let signature = xeddsa_sign(&secret, message, OsRng); + + let wrong_secret = StaticSecret::random_from_rng(OsRng); + let wrong_public = PublicKey::from(&wrong_secret); + + assert_eq!( + xeddsa_verify(&wrong_public, message, &signature), + Err(SignatureError) + ); + } + + #[test] + fn test_wrong_message_fails() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let message = b"test message"; + + let signature = xeddsa_sign(&secret, message, OsRng); + + assert_eq!( + xeddsa_verify(&public, b"wrong message", &signature), + Err(SignatureError) + ); + } + + #[test] + fn test_corrupted_signature_fails() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let message = b"test message"; + + let mut signature = xeddsa_sign(&secret, message, OsRng); + signature.0[0] ^= 0xFF; + + assert_eq!( + xeddsa_verify(&public, message, &signature), + Err(SignatureError) + ); + } +}