feat: update introduction bundle encoding

closes: #27
This commit is contained in:
Patryk Osmaczko 2026-02-09 21:51:57 +01:00
parent cd737ea058
commit 3d6ac7dcf5
No known key found for this signature in database
GPG Key ID: 6A385380FD275B44
10 changed files with 298 additions and 52 deletions

4
Cargo.lock generated
View File

@ -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",

View File

@ -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" }

View File

@ -107,8 +107,7 @@ impl Context {
}
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, 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<dyn Convo>) -> ConversationIdOwned {

View File

@ -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<AddressedEncryptedPayload>), 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);

View File

@ -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,
};

View File

@ -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<u8> {
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<R: RngCore + CryptoRng>(
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<PrekeyBundle> for Introduction {
fn from(value: PrekeyBundle) -> Self {
Introduction {
installation_key: value.identity_key,
ephemeral_key: value.signed_prekey,
}
}
pub signature: Ed25519Signature,
}
impl From<Introduction> for Vec<u8> {
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<u8> {
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<Self, Self::Error> {
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<u8> = 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<u8> = 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());
}
}

View File

@ -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"

View File

@ -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};

View File

@ -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<PublicKey>,
}
@ -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,
};

129
crypto/src/xeddsa_sign.rs Normal file
View File

@ -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<R: RngCore + CryptoRng>(
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)
);
}
}