diff --git a/Cargo.lock b/Cargo.lock index f4743ec..e3d992c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3250,6 +3250,7 @@ dependencies = [ "double-ratchets", "hashgraph-like-consensus", "hex", + "logos-account", "openmls", "openmls_libcrux_crypto 0.3.1", "openmls_memory_storage 0.5.0", @@ -3590,8 +3591,9 @@ name = "logos-account" version = "0.1.0" dependencies = [ "crypto", - "libchat", + "hex", "shared-traits", + "thiserror", ] [[package]] diff --git a/core/account/Cargo.toml b/core/account/Cargo.toml index dbf71a9..c4d24c6 100644 --- a/core/account/Cargo.toml +++ b/core/account/Cargo.toml @@ -9,7 +9,8 @@ dev = [] [dependencies] # Workspace dependencies (sorted) crypto = { workspace = true } -libchat = { workspace = true } shared-traits = { workspace = true } # External dependencies (sorted) +hex = "0.4.3" +thiserror = "2.0.17" diff --git a/core/account/src/account.rs b/core/account/src/account.rs index db2959b..8e34fc5 100644 --- a/core/account/src/account.rs +++ b/core/account/src/account.rs @@ -1,7 +1,5 @@ use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; -use shared_traits::{IdentId, IdentIdRef}; - -use libchat::IdentityProvider; +use shared_traits::{IdentId, IdentIdRef, IdentityProvider}; /// A Test Focused LogosAccount using a pre-defined identifier. /// The test account is not persisted, and uses a single user provided id. diff --git a/core/account/src/credential.rs b/core/account/src/credential.rs new file mode 100644 index 0000000..395b655 --- /dev/null +++ b/core/account/src/credential.rs @@ -0,0 +1,242 @@ +//! Account ↔ LocalIdentity binding carried inside the MLS leaf credential. +//! +//! A group message in MLS is signed by a per-device key — the **LocalIdentity** +//! (a device/installation). On its own that key says nothing about which +//! **Account** the device belongs to, so multiple LocalIdentities of one Account +//! cannot be collapsed back to that Account on the receiving side. +//! +//! This module binds the two together *inside the credential itself*, so a +//! receiver resolves both without any network round-trip or trusted directory: +//! +//! - the MLS leaf's signature key is the LocalIdentity device key (MLS already +//! proves the sender holds its private half), and +//! - the credential content carries the Account key plus the Account's signature +//! endorsing that exact device key. +//! +//! [`resolve_sender`] verifies the endorsement against the device key the leaf +//! actually signs with, yielding a [`MessageSender`] the application can trust. +//! This is the inverse of the account → device directory in `libchat` +//! (`account_directory`): that resolves an Account *to* its devices for invites; +//! this resolves a device *back to* its Account on receipt. + +use crypto::{Ed25519Signature, Ed25519VerifyingKey}; +use shared_traits::IdentId; +use thiserror::Error; + +/// Current credential content version. Bump when [`encode_credential`] changes. +pub const CREDENTIAL_VERSION: u8 = 1; + +/// Domain-separation tag prepended to the credential content and folded into the +/// endorsement message. The account key may sign other things (e.g. the device +/// bundle in `account_directory`), so binding to this exact purpose stops a +/// signature obtained elsewhere from being replayed here. The trailing NUL keeps +/// it from being a prefix of any other domain. +pub const CREDENTIAL_DOMAIN: &[u8] = b"libchat:account-local-identity\0"; + +/// The verified sender of a group message: which Account, and which device +/// (LocalIdentity) of that Account it was sent from. Both are hex-encoded +/// Ed25519 verifying keys. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MessageSender { + /// The Account the sending device belongs to. + pub account: IdentId, + /// The specific LocalIdentity (device/installation) that sent the message. + pub local_identity: IdentId, +} + +/// Failures decoding or verifying an account-bound credential. +#[derive(Debug, Error)] +pub enum CredentialError { + #[error("credential is missing the account-local-identity domain prefix")] + Domain, + #[error("credential shorter than its declared layout")] + Short, + #[error("unsupported credential version {0}")] + Version(u8), + #[error("credential carries a malformed account key")] + AccountKey, + #[error("account endorsement of the local identity failed verification")] + SignatureInvalid, +} + +/// The bytes the Account signs to endorse `device` as one of its LocalIdentities. +/// +/// `account` is folded in alongside the device key so the signature is bound to +/// the specific (account, device) pair, not just the device key in isolation. +fn endorsement_message(account: &Ed25519VerifyingKey, device: &Ed25519VerifyingKey) -> Vec { + let mut msg = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 32 + 32); + msg.extend_from_slice(CREDENTIAL_DOMAIN); + msg.extend_from_slice(account.as_ref()); + msg.extend_from_slice(device.as_ref()); + msg +} + +/// Produce the Account's endorsement of `device`, using the Account's signing +/// capability. `sign` is the account authority's signer (a local key on testnet, +/// or an external wallet/enclave), so the closure is fallible. +pub fn endorse_local_identity( + account: &Ed25519VerifyingKey, + device: &Ed25519VerifyingKey, + sign: impl FnOnce(&[u8]) -> Result, +) -> Result { + sign(&endorsement_message(account, device)) +} + +/// Encode the MLS credential content that binds a LocalIdentity to its Account. +/// +/// The device key is *not* embedded: it is the leaf's signature key, supplied +/// out-of-band by MLS on the receiving side and passed to [`resolve_sender`]. +/// +/// ```text +/// domain : CREDENTIAL_DOMAIN (constant prefix, NUL-terminated) +/// version : u8 (1 byte) +/// account_pub : [u8; 32] (32 bytes) +/// endorsement : [u8; 64] (64 bytes) — account signature over endorsement_message +/// ``` +pub fn encode_credential(account: &Ed25519VerifyingKey, endorsement: &Ed25519Signature) -> Vec { + let mut out = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 1 + 32 + 64); + out.extend_from_slice(CREDENTIAL_DOMAIN); + out.push(CREDENTIAL_VERSION); + out.extend_from_slice(account.as_ref()); + out.extend_from_slice(endorsement.as_ref()); + out +} + +/// Decode `credential_content` and verify the Account's endorsement against +/// `device_key` — the key the MLS leaf actually signs with. On success the +/// caller learns both the LocalIdentity (`device_key`) and the Account that +/// vouches for it. +pub fn resolve_sender( + credential_content: &[u8], + device_key: &Ed25519VerifyingKey, +) -> Result { + const HEADER: usize = 1 + 32 + 64; + let rest = credential_content + .strip_prefix(CREDENTIAL_DOMAIN) + .ok_or(CredentialError::Domain)?; + if rest.len() < HEADER { + return Err(CredentialError::Short); + } + let version = rest[0]; + if version != CREDENTIAL_VERSION { + return Err(CredentialError::Version(version)); + } + + let account_bytes: [u8; 32] = rest[1..33].try_into().expect("33 - 1 == 32"); + let account = + Ed25519VerifyingKey::from_bytes(&account_bytes).map_err(|_| CredentialError::AccountKey)?; + let sig_bytes: [u8; 64] = rest[33..97].try_into().expect("97 - 33 == 64"); + let endorsement = Ed25519Signature::from(sig_bytes); + + // Verifying under the account key, over a message that includes the *leaf's* + // device key, is what binds this device to this account. A credential lifted + // from another sender won't verify against the device key MLS hands us here. + account + .verify(&endorsement_message(&account, device_key), &endorsement) + .map_err(|_| CredentialError::SignatureInvalid)?; + + Ok(MessageSender { + account: IdentId::new(hex::encode(account.as_ref())), + local_identity: IdentId::new(hex::encode(device_key.as_ref())), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto::Ed25519SigningKey; + use std::convert::Infallible; + + /// Sign with the account key, resolve under the device key: both identities + /// come back, hex-encoded. + #[test] + fn resolves_well_formed_credential() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let device_key = Ed25519SigningKey::generate(); + let device_pub = device_key.verifying_key(); + + let endorsement = endorse_local_identity::(&account_pub, &device_pub, |m| { + Ok(account_key.sign(m)) + }) + .unwrap(); + let content = encode_credential(&account_pub, &endorsement); + + let sender = resolve_sender(&content, &device_pub).unwrap(); + assert_eq!(sender.account.as_str(), hex::encode(account_pub.as_ref())); + assert_eq!( + sender.local_identity.as_str(), + hex::encode(device_pub.as_ref()) + ); + } + + /// On testnet the account key *is* the device key (one device == one + /// account); resolution then reports the same id for both. + #[test] + fn single_key_account_resolves_to_itself() { + let key = Ed25519SigningKey::generate(); + let pubkey = key.verifying_key(); + + let endorsement = + endorse_local_identity::(&pubkey, &pubkey, |m| Ok(key.sign(m))).unwrap(); + let content = encode_credential(&pubkey, &endorsement); + + let sender = resolve_sender(&content, &pubkey).unwrap(); + assert_eq!(sender.account, sender.local_identity); + } + + /// An endorsement for device A, presented with device B's key (as MLS would + /// hand it over if B forged a leaf), fails: the signature covers A's key. + #[test] + fn rejects_endorsement_for_a_different_device() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let device_a = Ed25519SigningKey::generate().verifying_key(); + let device_b = Ed25519SigningKey::generate().verifying_key(); + + let endorsement = endorse_local_identity::(&account_pub, &device_a, |m| { + Ok(account_key.sign(m)) + }) + .unwrap(); + let content = encode_credential(&account_pub, &endorsement); + + assert!(matches!( + resolve_sender(&content, &device_b), + Err(CredentialError::SignatureInvalid) + )); + } + + #[test] + fn rejects_missing_domain_short_and_bad_version() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let device = Ed25519SigningKey::generate().verifying_key(); + let endorsement = + endorse_local_identity::( + &account_pub, + &device, + |m| Ok(account_key.sign(m)), + ) + .unwrap(); + let content = encode_credential(&account_pub, &endorsement); + + // Strip the domain prefix. + assert!(matches!( + resolve_sender(&content[CREDENTIAL_DOMAIN.len()..], &device), + Err(CredentialError::Domain) + )); + // Domain present but body truncated. + let short = &content[..CREDENTIAL_DOMAIN.len() + 4]; + assert!(matches!( + resolve_sender(short, &device), + Err(CredentialError::Short) + )); + // Wrong version byte (first byte after the domain prefix). + let mut bad_version = content.clone(); + bad_version[CREDENTIAL_DOMAIN.len()] = 99; + assert!(matches!( + resolve_sender(&bad_version, &device), + Err(CredentialError::Version(99)) + )); + } +} diff --git a/core/account/src/lib.rs b/core/account/src/lib.rs index c33c296..773fc83 100644 --- a/core/account/src/lib.rs +++ b/core/account/src/lib.rs @@ -1,3 +1,10 @@ +mod credential; + +pub use credential::{ + CREDENTIAL_DOMAIN, CREDENTIAL_VERSION, CredentialError, MessageSender, encode_credential, + endorse_local_identity, resolve_sender, +}; + #[cfg(feature = "dev")] mod account; diff --git a/core/conversations/Cargo.toml b/core/conversations/Cargo.toml index 52caa29..3ca2a1f 100644 --- a/core/conversations/Cargo.toml +++ b/core/conversations/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["rlib"] blake2 = { workspace = true } chat-sqlite = { workspace = true } crypto = { workspace = true } +logos-account = { workspace = true } shared-traits = { workspace = true } storage = { workspace = true } diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 0d0d433..49ad0d2 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -5,6 +5,8 @@ use blake2::{Blake2b, Digest, digest::consts::U6}; use chat_proto::logoschat::encryption::{EncryptedPayload, Plaintext, encrypted_payload}; use chat_proto::logoschat::reliability::ReliablePayload; +use crypto::Ed25519VerifyingKey; +use logos_account::MessageSender; use openmls::prelude::tls_codec::Deserialize; use openmls::prelude::*; use prost::Message as _; @@ -40,13 +42,9 @@ impl GroupV1Convo { // Create a new conversation with the creator as the only participant. pub fn new(cx: &mut ServiceContext) -> Result { let config = Self::mls_create_config(cx); - let mls_group = MlsGroup::new( - &cx.mls_provider, - &cx.mls_identity, - &config, - cx.mls_identity.get_credential(), - ) - .unwrap(); + let credential = cx.mls_identity.get_credential()?; + let mls_group = + MlsGroup::new(&cx.mls_provider, &cx.mls_identity, &config, credential).unwrap(); let convo_id = hex::encode(mls_group.group_id().as_slice()); Self::subscribe(&mut cx.ds, &convo_id)?; @@ -178,6 +176,26 @@ impl GroupV1Convo { &self.convo_id } + /// Resolve the verified [`MessageSender`] for a processed message. + /// + /// MLS guarantees the message was signed by the sender leaf's key, which is + /// the device (LocalIdentity) key. We pair that authoritative key with the + /// account binding carried in the credential to recover both the Account and + /// the LocalIdentity. Returns `None` for non-member senders or if the + /// credential isn't a well-formed, validly-endorsed account binding. + fn resolve_sender(&self, processed: &ProcessedMessage) -> Option { + let Sender::Member(leaf_index) = processed.sender() else { + return None; + }; + // The leaf's signature key is the device key MLS verified the message + // against — the trustworthy LocalIdentity, not anything self-asserted in + // the credential content. + let member = self.mls_group.member_at(*leaf_index)?; + let key_bytes: [u8; 32] = member.signature_key.as_slice().try_into().ok()?; + let device_key = Ed25519VerifyingKey::from_bytes(&key_bytes).ok()?; + logos_account::resolve_sender(processed.credential().serialized_content(), &device_key).ok() + } + fn send_message( &mut self, content: &[u8], @@ -252,6 +270,10 @@ impl Convo for GroupV1Convo { .process_message(&cx.mls_provider, protocol_message) .map_err(ChatError::generic)?; + // Resolve the sender before consuming `processed`; only surfaced + // alongside application content below. + let sender = self.resolve_sender(&processed); + let content = match processed.into_content() { ProcessedMessageContent::ApplicationMessage(msg) => { let reliable = ReliablePayload::decode(msg.into_bytes().as_slice())?; @@ -271,9 +293,14 @@ impl Convo for GroupV1Convo { None } }; + // A commit (content == None) has a sender but no application payload; + // keep the sender paired with content so consumers see it only on + // actual messages. + let sender = content.as_ref().and(sender); Ok(ConvoOutcome { convo_id: self.id().to_string(), content, + sender, }) } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index c8a42cb..aa0ff67 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -21,6 +21,7 @@ use de_mls::protos::de_mls::messages::v1::{ }; use de_mls::session::{Conversation, ConversationConfig, ConversationDeps}; use hashgraph_like_consensus::signing::EthereumConsensusSigner; +use logos_account::MessageSender; use prost::Message; use shared_traits::{IdentId, IdentIdRef}; use std::sync::Arc; @@ -460,6 +461,16 @@ impl GroupV2Convo { content: Some(Content { bytes: cm.message.clone(), }), + // de-mls carries only the sender's member-id (the LocalIdentity + // display string); it does not yet expose an account-bound + // credential the way GroupV1 does. Surface the LocalIdentity and + // treat it as its own Account (the testnet 1:1 case). + // TODO: resolve the Account once de-mls exposes the sender + // credential, mirroring GroupV1Convo::resolve_sender. + sender: Some(MessageSender { + account: IdentId::new(cm.sender.clone()), + local_identity: IdentId::new(cm.sender.clone()), + }), }), _ => None, }) diff --git a/core/conversations/src/conversation/privatev1.rs b/core/conversations/src/conversation/privatev1.rs index 8bb1a1e..80db85e 100644 --- a/core/conversations/src/conversation/privatev1.rs +++ b/core/conversations/src/conversation/privatev1.rs @@ -273,6 +273,8 @@ impl Convo for PrivateV1Convo { Ok(ConvoOutcome { convo_id: self.id().to_string(), content, + // TODO: surface the peer identity for 1:1 conversations. + sender: None, }) } diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index 23589ff..57d68c0 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -205,14 +205,10 @@ impl InboxV2 { .ciphersuites(vec![CIPHER_SUITE]) .extensions(vec![ExtensionType::ApplicationId]) .build(); + let credential = cx.mls_identity.get_credential()?; let a = KeyPackage::builder() .leaf_node_capabilities(capabilities) - .build( - CIPHER_SUITE, - &cx.mls_provider, - &cx.mls_identity, - cx.mls_identity.get_credential(), - ) + .build(CIPHER_SUITE, &cx.mls_provider, &cx.mls_identity, credential) .expect("Failed to build KeyPackage"); Ok(a.key_package().clone()) diff --git a/core/conversations/src/inbox_v2/identity.rs b/core/conversations/src/inbox_v2/identity.rs index 779f90b..4dfa854 100644 --- a/core/conversations/src/inbox_v2/identity.rs +++ b/core/conversations/src/inbox_v2/identity.rs @@ -9,6 +9,7 @@ use openmls_traits::{ use shared_traits::IdentIdRef; use crate::AccountAuthority; +use crate::ChatError; use crate::IdentityProvider; /// A Wrapper for an IdentityProvider which provides MLS specific functionality @@ -23,11 +24,25 @@ impl MlsIdentityProvider { Self(inner) } - pub fn get_credential(&self) -> CredentialWithKey { - CredentialWithKey { - credential: BasicCredential::new(self.id().as_str().as_bytes().to_vec()).into(), - signature_key: self.public_key().as_ref().into(), - } + /// Build the MLS leaf credential for this identity. + /// + /// The credential content binds this device (LocalIdentity) to its Account: + /// the leaf's signature key is the device key, and the content carries the + /// Account key plus the Account's endorsement of that device key. A receiver + /// recovers both via [`logos_account::resolve_sender`]. On testnet the + /// account key *is* the device key, so the endorsement is self-signed. + pub fn get_credential(&self) -> Result { + let account = AccountAuthority::account_pub(self).clone(); + let device = self.public_key().clone(); + let endorsement = logos_account::endorse_local_identity(&account, &device, |msg| { + AccountAuthority::sign(self, msg) + }) + .map_err(|e| ChatError::Generic(e.to_string()))?; + let content = logos_account::encode_credential(&account, &endorsement); + Ok(CredentialWithKey { + credential: BasicCredential::new(content).into(), + signature_key: device.as_ref().into(), + }) } } diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index b701b12..fa8ff09 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -23,6 +23,7 @@ pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; pub use core::{ConversationId, Core, Introduction}; pub use errors::ChatError; +pub use logos_account::MessageSender; pub use outcomes::{ Content, ConversationClass, ConvoOutcome, InboxOutcome, NewConversation, PayloadOutcome, }; diff --git a/core/conversations/src/outcomes.rs b/core/conversations/src/outcomes.rs index 3209da8..1cb6545 100644 --- a/core/conversations/src/outcomes.rs +++ b/core/conversations/src/outcomes.rs @@ -6,6 +6,7 @@ //! initial [`ConvoOutcome`]. //! - [`PayloadOutcome`] — the union of the above, plus `Empty`. +use logos_account::MessageSender; use storage::ConversationKind; use crate::conversation::ConversationId; @@ -19,6 +20,11 @@ pub struct Content { pub struct ConvoOutcome { pub convo_id: ConversationId, pub content: Option, + /// The verified sender of `content`: both the Account and the specific + /// LocalIdentity (device) it was sent from. `None` for control messages + /// (e.g. MLS commits) that carry no application content, and for + /// conversation types that don't yet surface a sender. + pub sender: Option, } impl ConvoOutcome { @@ -26,6 +32,7 @@ impl ConvoOutcome { Self { convo_id, content: None, + sender: None, } } } diff --git a/core/integration_tests_core/src/test_client.rs b/core/integration_tests_core/src/test_client.rs index 3843f93..47f6ab9 100644 --- a/core/integration_tests_core/src/test_client.rs +++ b/core/integration_tests_core/src/test_client.rs @@ -1,4 +1,4 @@ -use libchat::{ConversationId, Core, IdentityProvider, PayloadOutcome}; +use libchat::{ConversationId, Core, IdentityProvider, MessageSender, PayloadOutcome}; use logos_account::TestLogosAccount; use shared_traits::IdentId; use std::collections::HashMap; @@ -34,6 +34,8 @@ type ClientType = Core<( pub struct ReceivedMessage { pub convo_id: ConversationId, pub contents: T, + /// The verified sender (Account + LocalIdentity) surfaced with the message. + pub sender: Option, } pub struct TestClient { @@ -76,6 +78,7 @@ impl TestClient { self.received_messages.push(ReceivedMessage { convo_id: convo_outcome.convo_id.clone(), contents: data.bytes.clone(), + sender: convo_outcome.sender.clone(), }); } } @@ -102,6 +105,15 @@ impl TestClient { false } + /// The verified sender recorded for the (first) message matching + /// `convo_id`/`content`, if any was received. + pub fn sender_of(&self, convo_id: &str, content: &[u8]) -> Option<&MessageSender> { + self.received_messages + .iter() + .find(|m| m.convo_id == convo_id && m.contents == content) + .and_then(|m| m.sender.as_ref()) + } + pub fn convo_count(&self) -> usize { self.list_conversations().map_or(0, |v| v.len()) } diff --git a/core/integration_tests_core/tests/mls_integration.rs b/core/integration_tests_core/tests/mls_integration.rs index d80e690..e017b9e 100644 --- a/core/integration_tests_core/tests/mls_integration.rs +++ b/core/integration_tests_core/tests/mls_integration.rs @@ -57,4 +57,30 @@ fn create_group() { assert!(!harness.pax().check(&convo_id, M_R1)); assert!(!harness.pax().check(&convo_id, M_P1)); + + // Every delivered message carries a verified sender: both the Account and + // the LocalIdentity it was sent from. On testnet each identity is its own + // single-device account, so the two resolve to the same key. + let raya_sender = harness + .saro() + .sender_of(&convo_id, M_R1) + .expect("Saro should see who sent Raya's message") + .clone(); + assert_eq!( + raya_sender.account, raya_sender.local_identity, + "single-key testnet account resolves Account == LocalIdentity" + ); + + let pax_sender = harness + .saro() + .sender_of(&convo_id, M_P1) + .expect("Saro should see who sent Pax's message") + .clone(); + + // Distinct identities resolve to distinct senders — the basis for telling + // group members apart and for collapsing an account's devices to one Account. + assert_ne!( + raya_sender.account, pax_sender.account, + "Raya and Pax must resolve to different accounts" + ); } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 17b5439..08f7404 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -261,11 +261,16 @@ fn events_from_inbound(result: PayloadOutcome) -> Vec { } fn convo_events(outcome: ConvoOutcome) -> Vec { - let ConvoOutcome { convo_id, content } = outcome; + let ConvoOutcome { + convo_id, + content, + sender, + } = outcome; content .map(|c| Event::MessageReceived { convo_id: Arc::from(convo_id), content: c.bytes, + sender, }) .into_iter() .collect() @@ -282,10 +287,13 @@ fn inbox_events(outcome: InboxOutcome) -> Vec { convo_id: Arc::clone(&id), class: new_conversation.class, }); - if let Some(c) = initial.and_then(|co| co.content) { + if let Some(co) = initial + && let Some(c) = co.content + { events.push(Event::MessageReceived { convo_id: Arc::clone(&id), content: c.bytes, + sender: co.sender, }); } events diff --git a/crates/client/src/event.rs b/crates/client/src/event.rs index e704050..c16da30 100644 --- a/crates/client/src/event.rs +++ b/crates/client/src/event.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use libchat::ConversationClass; +use libchat::{ConversationClass, MessageSender}; /// A discrete chat event. #[non_exhaustive] @@ -23,6 +23,10 @@ pub enum Event { MessageReceived { convo_id: Arc, content: Vec, + /// The verified sender — both the Account and the LocalIdentity + /// (device) it was sent from. `None` when the conversation type does + /// not yet surface a sender. + sender: Option, }, InboundError { message: String, diff --git a/crates/client/tests/saro_and_raya.rs b/crates/client/tests/saro_and_raya.rs index b588240..2299cef 100644 --- a/crates/client/tests/saro_and_raya.rs +++ b/crates/client/tests/saro_and_raya.rs @@ -37,7 +37,9 @@ fn saro_raya_message_exchange() { other => Err(other), }); expect_event(&raya_events, "MessageReceived", |e| match e { - Event::MessageReceived { convo_id, content } => { + Event::MessageReceived { + convo_id, content, .. + } => { assert_eq!(convo_id, raya_convo_id); assert_eq!(content.as_slice(), b"hello raya"); Ok(())