Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions conversations/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ edition = "2024"
crate-type = ["staticlib","dylib"]

[dependencies]
base64 = "0.22"
blake2.workspace = true
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
crypto = { path = "../crypto" }
Expand Down
5 changes: 2 additions & 3 deletions conversations/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ impl Context {
.invite_to_private_convo(remote_bundle, content)
.unwrap_or_else(|_| todo!("Log/Surface Error"));

let remote_id = Inbox::inbox_identifier_for_key(remote_bundle.installation_key);
let remote_id = Inbox::inbox_identifier_for_key(*remote_bundle.installation_key());
let payload_bytes = payloads
.into_iter()
.map(|p| p.into_envelope(remote_id.clone()))
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 13 additions & 19 deletions conversations/src/inbox/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,14 @@ 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 = (&ephemeral).into();
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,
}
Introduction::new(self.ident.secret(), ephemeral_key, OsRng)
}

pub fn invite_to_private_convo(
Expand All @@ -73,18 +68,17 @@ 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],
identity_key: *remote_bundle.installation_key(),
signed_prekey: *remote_bundle.ephemeral_key(),
signature: *remote_bundle.signature(),
onetime_prekey: None,
};

let (seed_key, ephemeral_pub) =
InboxHandshake::perform_as_initiator(self.ident.secret(), &pkb, &mut rng);

let mut convo = PrivateV1Convo::new_initiator(seed_key, remote_bundle.ephemeral_key);
let mut convo = PrivateV1Convo::new_initiator(seed_key, *remote_bundle.ephemeral_key());

let mut payloads = convo.send_message(initial_message)?;

Expand All @@ -99,8 +93,8 @@ impl Inbox {
let header = proto::InboxHeaderV1 {
initiator_static: self.ident.public_key().copy_to_bytes(),
initiator_ephemeral: ephemeral_pub.copy_to_bytes(),
responder_static: remote_bundle.installation_key.copy_to_bytes(),
responder_ephemeral: remote_bundle.ephemeral_key.copy_to_bytes(),
responder_static: remote_bundle.installation_key().copy_to_bytes(),
responder_ephemeral: remote_bundle.ephemeral_key().copy_to_bytes(),
};

let handshake = proto::InboxHandshakeV1 {
Expand All @@ -110,7 +104,7 @@ impl Inbox {

// Update the address field with the Inbox delivery_Address
first_message.delivery_address =
delivery_address_for_installation(remote_bundle.installation_key);
delivery_address_for_installation(*remote_bundle.installation_key());
// Update the data field with new Payload
first_message.data = proto::EncryptedPayload {
encryption: Some(proto::Encryption::InboxHandshake(handshake)),
Expand Down Expand Up @@ -244,9 +238,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);
Expand Down
2 changes: 1 addition & 1 deletion conversations/src/inbox/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
190 changes: 161 additions & 29 deletions conversations/src/inbox/introduction.rs
Original file line number Diff line number Diff line change
@@ -1,64 +1,196 @@
use crypto::PrekeyBundle;
use x25519_dalek::PublicKey;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
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,
installation_key: PublicKey,
ephemeral_key: PublicKey,
signature: Ed25519Signature,
}

impl From<PrekeyBundle> for Introduction {
fn from(value: PrekeyBundle) -> Self {
Introduction {
installation_key: value.identity_key,
ephemeral_key: value.signed_prekey,
impl Introduction {
/// Create a new `Introduction` by signing the ephemeral key with the installation secret.
pub(crate) fn new<R: RngCore + CryptoRng>(
installation_secret: &StaticSecret,
ephemeral_key: PublicKey,
rng: R,
) -> Self {
let installation_key = installation_secret.into();
let signature = sign_intro_binding(installation_secret, &ephemeral_key, rng);
Self {
installation_key,
ephemeral_key,
signature,
}
}

pub fn installation_key(&self) -> &PublicKey {
&self.installation_key
}

pub fn ephemeral_key(&self) -> &PublicKey {
&self.ephemeral_key
}

pub fn signature(&self) -> &Ed25519Signature {
&self.signature
}
}

impl From<Introduction> for Vec<u8> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch

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()),
};

let base64_encoded = URL_SAFE_NO_PAD.encode(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);

link.into_bytes()
result.into_bytes()
}
}

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 proto_bytes = URL_SAFE_NO_PAD
.decode(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] = hex::decode(parts[1])
.map_err(|_| ChatError::BadParsing("installation_key"))?
let installation_bytes: [u8; 32] = bundle
.installation_pubkey
.as_ref()
.try_into()
.map_err(|_| ChatError::InvalidKeyLength)?;
let installation_key = PublicKey::from(installation_bytes);

let ephemeral_bytes: [u8; 32] = hex::decode(parts[2])
.map_err(|_| ChatError::BadParsing("ephemeral_key"))?
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()))?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dust] From a defensive code perspective. I'd consider validating the signature here.

Its possible for this type to create an invariant that Introduction always contains a valid ephemeral key.
This would make reasoning about this object easier if it was never possible to create an invalid Introduction.

That would contain the validation logic within this file and make it harder for future contributors to make mistakes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, adapted the code 👍


let installation_key = PublicKey::from(installation_bytes);
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 ephemeral_secret = StaticSecret::random_from_rng(OsRng);
let ephemeral_pub: PublicKey = (&ephemeral_secret).into();

Introduction::new(&install_secret, ephemeral_pub, OsRng)
}

#[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());
}
}
1 change: 1 addition & 0 deletions crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions crypto/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
Loading