mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-27 19:49:31 +00:00
receiver recovers both the local identity and the account
This commit is contained in:
parent
e163980715
commit
9eee747182
4
Cargo.lock
generated
4
Cargo.lock
generated
@ -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]]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
242
core/account/src/credential.rs
Normal file
242
core/account/src/credential.rs
Normal file
@ -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<u8> {
|
||||
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<E>(
|
||||
account: &Ed25519VerifyingKey,
|
||||
device: &Ed25519VerifyingKey,
|
||||
sign: impl FnOnce(&[u8]) -> Result<Ed25519Signature, E>,
|
||||
) -> Result<Ed25519Signature, E> {
|
||||
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<u8> {
|
||||
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<MessageSender, CredentialError> {
|
||||
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::<Infallible>(&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::<Infallible>(&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::<Infallible>(&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::<Infallible>(
|
||||
&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))
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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<S: ExternalServices>(cx: &mut ServiceContext<S>) -> Result<Self, ChatError> {
|
||||
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<MessageSender> {
|
||||
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<S: ExternalServices>(
|
||||
&mut self,
|
||||
content: &[u8],
|
||||
@ -252,6 +270,10 @@ impl<S: ExternalServices> Convo<S> 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<S: ExternalServices> Convo<S> 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -273,6 +273,8 @@ impl<S: ExternalServices> Convo<S> for PrivateV1Convo {
|
||||
Ok(ConvoOutcome {
|
||||
convo_id: self.id().to_string(),
|
||||
content,
|
||||
// TODO: surface the peer identity for 1:1 conversations.
|
||||
sender: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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<T: IdentityProvider> MlsIdentityProvider<T> {
|
||||
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<CredentialWithKey, ChatError> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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<Content>,
|
||||
/// 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<MessageSender>,
|
||||
}
|
||||
|
||||
impl ConvoOutcome {
|
||||
@ -26,6 +32,7 @@ impl ConvoOutcome {
|
||||
Self {
|
||||
convo_id,
|
||||
content: None,
|
||||
sender: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<T> {
|
||||
pub convo_id: ConversationId,
|
||||
pub contents: T,
|
||||
/// The verified sender (Account + LocalIdentity) surfaced with the message.
|
||||
pub sender: Option<MessageSender>,
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -261,11 +261,16 @@ fn events_from_inbound(result: PayloadOutcome) -> Vec<Event> {
|
||||
}
|
||||
|
||||
fn convo_events(outcome: ConvoOutcome) -> Vec<Event> {
|
||||
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<Event> {
|
||||
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
|
||||
|
||||
@ -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<str>,
|
||||
content: Vec<u8>,
|
||||
/// 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<MessageSender>,
|
||||
},
|
||||
InboundError {
|
||||
message: String,
|
||||
|
||||
@ -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(())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user