mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-03 21:53:31 +00:00
A GroupV2 add is staged as a proposal that a later commit merges, so between the two the invited member sits in the conversation's own state and nowhere in its public roster, leaving a caller no way to observe an invite in flight. group_members now returns those invites after the committed members, flagged pending. The flag is inviter-local and transient: a conversation records only the joiners this client proposed, and drops each one as the commit admitting it lands. GroupV1 never reports a pending member, since its add merges its own commit before returning.
916 lines
34 KiB
Rust
916 lines
34 KiB
Rust
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
use std::thread::{self, JoinHandle};
|
|
|
|
use components::{ThreadedWakeupService, WakeupEvent};
|
|
use crossbeam_channel::{Receiver, Sender, select};
|
|
use crypto::Ed25519VerifyingKey;
|
|
use libchat::{
|
|
ConversationId, ConvoMetadata, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId,
|
|
IdentIdRef, InboxOutcome, PayloadOutcome, RegistrationService,
|
|
};
|
|
use logos_account::{AccountDirectory, resolve_device_ids};
|
|
use parking_lot::Mutex;
|
|
use storage::ChatStore;
|
|
|
|
use crate::delegate::{DelegateCredential, DelegateIdentity, DelegateSigner};
|
|
use crate::errors::ClientError;
|
|
use crate::event::{Event, MessageSender};
|
|
|
|
type ClientCore<T, R, S> = Core<(DelegateIdentity, T, R, ThreadedWakeupService, S)>;
|
|
type AccountAddressRef<'a> = &'a str;
|
|
type LocalSignerId = IdentId;
|
|
|
|
/// A member of a group conversation's roster.
|
|
///
|
|
/// Shares [`MessageSender`]'s field semantics: `account` is set only when the
|
|
/// member's credential claimed an account *and* the directory confirmed this
|
|
/// device belongs to it. Unlike a message sender, an unconfirmable claim does
|
|
/// not hide the member: a committed member is cryptographically in the group,
|
|
/// so it is listed by `local_identity` (its device) with `account: None`.
|
|
///
|
|
/// `pending` marks a member whose add the group has not committed yet, so it
|
|
/// cannot read the conversation. Only invites this client sent are reported;
|
|
/// an add another member proposed is invisible until it commits. The flag
|
|
/// clears when the commit admitting the member lands, and an invite the group
|
|
/// never commits stays pending for the life of the conversation.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct GroupMember {
|
|
pub account: Option<IdentId>,
|
|
pub local_identity: IdentId,
|
|
pub pending: bool,
|
|
}
|
|
|
|
/// Metadata a caller supplies when creating a group: its shared name and
|
|
/// description. Distinct from [`ConvoMetadata`], the type a conversation
|
|
/// reports back — the two carry different concerns and evolve independently
|
|
/// (the reported metadata may grow fields a caller cannot set).
|
|
#[derive(Debug, Clone)]
|
|
pub struct GroupMetadata {
|
|
pub name: String,
|
|
pub desc: String,
|
|
}
|
|
|
|
impl GroupMetadata {
|
|
pub fn new(name: impl Into<String>, desc: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
desc: desc.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The transport as the client sees it: a [`DeliveryService`] for outbound
|
|
/// publishing plus the inbound payload stream the worker drains. One object owns
|
|
/// both directions of the boundary.
|
|
pub trait Transport: DeliveryService + Send + 'static {
|
|
/// Hand over the inbound payload stream. Called once, at client construction,
|
|
/// before the [`Core`] takes ownership of the service.
|
|
fn inbound(&mut self) -> Receiver<Vec<u8>>;
|
|
}
|
|
|
|
/// High-level chat client.
|
|
///
|
|
/// Owns the synchronous [`Core`] behind an `Arc<Mutex<…>>` and a background
|
|
/// worker that consumes inbound payloads off the transport's channel, drives
|
|
/// the core, and forwards observations as [`Event`]s. Construction returns the
|
|
/// handle together with the `Receiver<Event>` the application drains on its own
|
|
/// schedule.
|
|
///
|
|
/// Outbound calls (`send_message`, `create_conversation`, …) run on the
|
|
/// caller's thread: they briefly lock the core, invoke it, and return — no
|
|
/// message-passing round-trip. The `Arc`/`Mutex`/threads live entirely here;
|
|
/// the core never mentions threads.
|
|
pub struct ChatClient<T, R, S>
|
|
where
|
|
T: Transport + Send + 'static,
|
|
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
|
|
S: ChatStore + Send + 'static,
|
|
{
|
|
/// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't
|
|
/// starve caller operations of the lock.
|
|
core: Arc<Mutex<ClientCore<T, R, S>>>,
|
|
/// The account → device directory. On testnet the registration service
|
|
/// doubles as the directory (one deployed registry serves both roles), so
|
|
/// the client keeps its own clone of `R`; the core sees key packages only.
|
|
directory: R,
|
|
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
|
|
shutdown: Option<Sender<()>>,
|
|
worker: Option<JoinHandle<()>>,
|
|
address: String,
|
|
}
|
|
|
|
// -- GenericChatClient
|
|
impl<T, R, S> ChatClient<T, R, S>
|
|
where
|
|
T: Transport + Send + 'static,
|
|
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
|
|
S: ChatStore + Send + 'static,
|
|
{
|
|
pub fn new(
|
|
ident: DelegateSigner,
|
|
account: String,
|
|
mut transport: T,
|
|
reg: R,
|
|
storage: S,
|
|
group_v2: Option<GroupV2Config>,
|
|
) -> Result<(Self, Receiver<Event>), ClientError> {
|
|
let inbound = transport.inbound();
|
|
|
|
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
|
|
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
|
|
let directory = reg.clone();
|
|
let ident = DelegateIdentity::new(ident, &account);
|
|
let mut core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
|
|
if let Some(config) = group_v2 {
|
|
core.set_group_v2_config(config);
|
|
}
|
|
Ok(Self::spawn(core, directory, account, inbound, wakeup_rx))
|
|
}
|
|
|
|
fn spawn(
|
|
core: ClientCore<T, R, S>,
|
|
directory: R,
|
|
address: String,
|
|
inbound: Receiver<Vec<u8>>,
|
|
wakeup_events: Receiver<WakeupEvent>,
|
|
) -> (Self, Receiver<Event>) {
|
|
let core = Arc::new(Mutex::new(core));
|
|
let (event_tx, event_rx) = crossbeam_channel::unbounded();
|
|
let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded::<()>(0);
|
|
|
|
let worker = thread::spawn({
|
|
let core = Arc::clone(&core);
|
|
let directory = directory.clone();
|
|
move || {
|
|
worker_loop(
|
|
core,
|
|
directory,
|
|
inbound,
|
|
wakeup_events,
|
|
shutdown_rx,
|
|
event_tx,
|
|
)
|
|
}
|
|
});
|
|
|
|
(
|
|
Self {
|
|
core,
|
|
directory,
|
|
shutdown: Some(shutdown_tx),
|
|
worker: Some(worker),
|
|
address,
|
|
},
|
|
event_rx,
|
|
)
|
|
}
|
|
|
|
/// The account address peers use to reach this client.
|
|
pub fn addr(&self) -> &str {
|
|
&self.address
|
|
}
|
|
|
|
/// Returns the installation name (identity label) of this client.
|
|
pub fn installation_name(&self) -> String {
|
|
self.core.lock().installation_name().to_string()
|
|
}
|
|
|
|
// Creates a conversation between two Accounts.
|
|
pub fn create_direct_conversation(
|
|
&mut self,
|
|
account: AccountAddressRef,
|
|
) -> Result<ConversationId, ClientError> {
|
|
let signers = self.signers_from_account(account)?;
|
|
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
|
|
|
|
self.core
|
|
.lock()
|
|
.create_direct_convo(&signer_refs)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// Create a GroupV2 conversation with the given accounts' devices. Each
|
|
/// account resolves to the signer ids its directory bundle endorses; the
|
|
/// group invite goes to every one of them. An empty slice creates a group
|
|
/// with only this client, to grow via [`Self::add_group_members`].
|
|
/// `metadata` becomes the group's shared name and description, carried to
|
|
/// every joiner in the welcome and readable via [`Self::group_metadata`];
|
|
/// both fields may be empty.
|
|
pub fn create_group_conversation(
|
|
&mut self,
|
|
accounts: &[AccountAddressRef],
|
|
metadata: GroupMetadata,
|
|
) -> Result<ConversationId, ClientError> {
|
|
let signers = self.signers_from_accounts(accounts)?;
|
|
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
|
|
|
|
self.core
|
|
.lock()
|
|
.create_group_convo_v2(&signer_refs, &metadata.name, &metadata.desc)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// Add accounts' devices to an existing group conversation. The add is
|
|
/// staged as an MLS proposal and merged by the group's next commit (driven
|
|
/// asynchronously by the wakeup loop); each joiner's welcome is sent when
|
|
/// that commit lands, not when this call returns.
|
|
pub fn add_group_members(
|
|
&mut self,
|
|
convo_id: &str,
|
|
accounts: &[AccountAddressRef],
|
|
) -> Result<(), ClientError> {
|
|
let signers = self.signers_from_accounts(accounts)?;
|
|
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
|
|
|
|
self.core
|
|
.lock()
|
|
.group_add_member(convo_id, &signer_refs)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// The group's roster, one [`GroupMember`] per account (self included),
|
|
/// committed members first and this client's uncommitted invites after
|
|
/// them, flagged `pending`. An account's several devices collapse to a
|
|
/// single entry surfacing that account; a member whose account claim the
|
|
/// directory can't confirm stays on the roster individually, keyed by its
|
|
/// device. An account that is both committed and pending collapses to its
|
|
/// committed entry. Costs one directory lookup per member that claims an
|
|
/// account, the same per-member cost a received message's sender check pays.
|
|
pub fn group_members(&mut self, convo_id: &str) -> Result<Vec<GroupMember>, ClientError> {
|
|
let (committed, pending) = {
|
|
let mut core = self.core.lock();
|
|
(
|
|
core.group_members(convo_id)?,
|
|
core.group_pending_members(convo_id)?,
|
|
)
|
|
};
|
|
let members = committed
|
|
.iter()
|
|
.filter_map(|credential| roster_member(&self.directory, credential))
|
|
.chain(pending.iter().filter_map(|credential| {
|
|
roster_member(&self.directory, credential).map(|member| GroupMember {
|
|
pending: true,
|
|
..member
|
|
})
|
|
}));
|
|
Ok(dedup_members(members))
|
|
}
|
|
|
|
/// The group's shared metadata (name and description), set at creation and
|
|
/// carried to every joiner in the welcome. Both fields may be empty. Fails
|
|
/// for a direct conversation and for a legacy group that carries no metadata.
|
|
pub fn group_metadata(&self, convo_id: &str) -> Result<ConvoMetadata, ClientError> {
|
|
self.core
|
|
.lock()
|
|
.convo_metadata(convo_id)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// List all conversation IDs known to this client.
|
|
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ClientError> {
|
|
self.core.lock().list_conversations().map_err(Into::into)
|
|
}
|
|
|
|
/// Encrypt and send `content` to an existing conversation. The core
|
|
/// publishes the outbound envelope.
|
|
pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> {
|
|
self.core
|
|
.lock()
|
|
.send_content(convo_id, content)
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// Resolve an account address to the signer (device) ids its published
|
|
/// directory bundle endorses. A reachable account has published at least
|
|
/// one signer; anything else is an error.
|
|
fn signers_from_account(
|
|
&self,
|
|
account: AccountAddressRef,
|
|
) -> Result<Vec<LocalSignerId>, ClientError> {
|
|
let account = IdentId::new(account.to_string());
|
|
let device_ids = resolve_device_ids(&self.directory, &account)
|
|
.map_err(|e| ClientError::AccountResolution(e.to_string()))?;
|
|
Ok(device_ids.into_iter().map(IdentId::new).collect())
|
|
}
|
|
|
|
/// Resolve each account to its signer ids and flatten them, failing on the
|
|
/// first unresolvable account.
|
|
fn signers_from_accounts(
|
|
&self,
|
|
accounts: &[AccountAddressRef],
|
|
) -> Result<Vec<LocalSignerId>, ClientError> {
|
|
let mut signers = Vec::new();
|
|
for account in accounts {
|
|
signers.extend(self.signers_from_account(account)?);
|
|
}
|
|
Ok(signers)
|
|
}
|
|
}
|
|
|
|
impl<T, R, S> Drop for ChatClient<T, R, S>
|
|
where
|
|
T: Transport + Send + 'static,
|
|
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
|
|
S: ChatStore + Send + 'static,
|
|
{
|
|
fn drop(&mut self) {
|
|
// Dropping the sender disconnects the worker's shutdown channel, waking
|
|
// its `select!` so it can exit; then we join it.
|
|
self.shutdown.take();
|
|
if let Some(handle) = self.worker.take() {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Background loop: block until an inbound payload or shutdown arrives, drive
|
|
/// the core on each payload, and forward events. No polling — `select!` parks
|
|
/// the thread until one of the channels is ready.
|
|
fn worker_loop<T, R, S: ChatStore + 'static>(
|
|
core: Arc<Mutex<ClientCore<T, R, S>>>,
|
|
directory: R,
|
|
inbound: Receiver<Vec<u8>>,
|
|
wakeup_events: Receiver<WakeupEvent>,
|
|
shutdown: Receiver<()>,
|
|
event_tx: Sender<Event>,
|
|
) where
|
|
T: DeliveryService + Send + 'static,
|
|
R: RegistrationService + AccountDirectory + Send + 'static,
|
|
{
|
|
loop {
|
|
select! {
|
|
recv(inbound) -> msg => {
|
|
let Ok(bytes) = msg else {
|
|
return; // transport's sender dropped
|
|
};
|
|
let events = {
|
|
let mut core = core.lock();
|
|
match core.handle_payload(&bytes) {
|
|
Ok(outcome) => events_from_inbound(outcome, &directory),
|
|
Err(e) => {
|
|
tracing::warn!("inbound handle_payload failed: {e:?}");
|
|
vec![Event::InboundError {
|
|
message: e.to_string(),
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
for event in events {
|
|
if event_tx.send(event).is_err() {
|
|
return; // application dropped the receiver
|
|
}
|
|
}
|
|
}
|
|
recv(wakeup_events) -> msg => {
|
|
let Ok(WakeupEvent { convo_id }) = msg else {
|
|
return; // wakeup service's sender dropped
|
|
};
|
|
// A wakeup can drive the steward's own commit, so it yields events too.
|
|
let events = match core.lock().wakeup(&convo_id) {
|
|
Ok(outcome) => events_from_inbound(outcome, &directory),
|
|
Err(e) => {
|
|
tracing::warn!("wakeup failed: {e:?}");
|
|
Vec::new()
|
|
}
|
|
};
|
|
for event in events {
|
|
if event_tx.send(event).is_err() {
|
|
return; // application dropped the receiver
|
|
}
|
|
}
|
|
}
|
|
recv(shutdown) -> _ => return,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Walk a [`PayloadOutcome`] in causal order and emit one `Event` per
|
|
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
|
|
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
|
|
/// per outcome and shared across the events it produces.
|
|
fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
|
match result {
|
|
PayloadOutcome::Empty => Vec::new(),
|
|
PayloadOutcome::Convo(co) => convo_events(co, directory),
|
|
PayloadOutcome::Inbox(io) => inbox_events(io, directory),
|
|
}
|
|
}
|
|
|
|
/// Interpret a hex account address as an Ed25519 account verifying key.
|
|
fn account_key_from_hex(addr: &str) -> Option<Ed25519VerifyingKey> {
|
|
let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?;
|
|
Ed25519VerifyingKey::from_bytes(&bytes).ok()
|
|
}
|
|
|
|
/// Why a message's sender could not be accepted, so the message is dropped.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum SenderError {
|
|
/// No credential at all, so no sender can be attributed. Every delivered
|
|
/// message must carry an explicit sender.
|
|
Missing,
|
|
/// Credential bytes were not valid hex.
|
|
NotHex,
|
|
/// Credential bytes did not decode to a delegate credential.
|
|
Malformed,
|
|
/// The claimed account address is not an Ed25519 verifying key.
|
|
AccountNotAKey,
|
|
/// The account → device mapping is wrong or could not be confirmed: the
|
|
/// device is not in the account's published set, the account published none,
|
|
/// or the directory lookup failed.
|
|
Unverified,
|
|
}
|
|
|
|
/// The resolution of a credential's account claim against the directory.
|
|
enum AccountClaim {
|
|
/// The credential claimed no account.
|
|
None,
|
|
/// Confirmed: the directory lists this device under the claimed account.
|
|
Verified(IdentId),
|
|
/// An account was claimed but could not be confirmed (see [`SenderError`]).
|
|
Unverified(SenderError),
|
|
}
|
|
|
|
/// Parse a wire credential into the device it names and the resolution of any
|
|
/// account claim, checked against the account → device directory. `Err` only
|
|
/// when no device can be attributed at all (missing or unparseable credential).
|
|
///
|
|
/// The account-claim policy is left to the caller: a message drops on an
|
|
/// unconfirmable claim, a roster entry keeps the device and forgoes the account.
|
|
fn parse_credential(
|
|
directory: &impl AccountDirectory,
|
|
encoded: &[u8],
|
|
) -> Result<(IdentId, AccountClaim), SenderError> {
|
|
// No credential at all: there is no device to attribute.
|
|
if encoded.is_empty() {
|
|
return Err(SenderError::Missing);
|
|
}
|
|
let Ok(data) = hex::decode(encoded) else {
|
|
tracing::warn!("credential is not valid hex");
|
|
return Err(SenderError::NotHex);
|
|
};
|
|
let Ok(cred) = DelegateCredential::try_from(data) else {
|
|
tracing::warn!("malformed credential");
|
|
return Err(SenderError::Malformed);
|
|
};
|
|
let device = IdentId::new(hex::encode(cred.delegate_id().as_ref()));
|
|
// An unassociated delegate asserts no account → device mapping.
|
|
let Some(account_addr) = cred.account_addr() else {
|
|
return Ok((device, AccountClaim::None));
|
|
};
|
|
let Some(account_key) = account_key_from_hex(account_addr) else {
|
|
tracing::warn!(account_addr, "account address is not a verifying key");
|
|
return Ok((
|
|
device,
|
|
AccountClaim::Unverified(SenderError::AccountNotAKey),
|
|
));
|
|
};
|
|
let claim = match directory.fetch(&account_key) {
|
|
Ok(Some(set)) if set.devices.iter().any(|d| d.as_str() == device.as_str()) => {
|
|
AccountClaim::Verified(IdentId::new(account_addr.to_string()))
|
|
}
|
|
_ => {
|
|
tracing::warn!(account_addr, device = %device.as_str(), "account → device mapping is wrong or unconfirmable");
|
|
AccountClaim::Unverified(SenderError::Unverified)
|
|
}
|
|
};
|
|
Ok((device, claim))
|
|
}
|
|
|
|
/// Decode and verify a message's sender from its credential, checked against the
|
|
/// account → device directory (our account store).
|
|
///
|
|
/// `Ok(sender)` — deliver with the sender; its `account` is set only when the
|
|
/// directory confirmed the device, so it is always verified. `Err` — drop the
|
|
/// message (including when no credential is present, since every delivered
|
|
/// message must carry an explicit sender).
|
|
fn decode_sender(
|
|
directory: &impl AccountDirectory,
|
|
encoded: &[u8],
|
|
) -> Result<MessageSender, SenderError> {
|
|
let (device, claim) = parse_credential(directory, encoded)?;
|
|
match claim {
|
|
AccountClaim::None => Ok(MessageSender {
|
|
account: None,
|
|
local_identity: device,
|
|
}),
|
|
AccountClaim::Verified(account) => Ok(MessageSender {
|
|
account: Some(account),
|
|
local_identity: device,
|
|
}),
|
|
// An unconfirmable account claim drops the message: every delivered
|
|
// message must carry a verified sender.
|
|
AccountClaim::Unverified(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
/// Map a group member's credential (as reported by MLS, in the same hex-encoded
|
|
/// form a message carries as its sender) to a roster entry, tolerating an
|
|
/// unconfirmable account claim by listing the device without an account. `None`
|
|
/// only when the credential cannot be parsed, which does not happen for a real
|
|
/// MLS leaf.
|
|
fn roster_member(directory: &impl AccountDirectory, encoded: &[u8]) -> Option<GroupMember> {
|
|
let (device, claim) = parse_credential(directory, encoded).ok()?;
|
|
let account = match claim {
|
|
AccountClaim::Verified(account) => Some(account),
|
|
AccountClaim::None | AccountClaim::Unverified(_) => None,
|
|
};
|
|
Some(GroupMember {
|
|
account,
|
|
local_identity: device,
|
|
pending: false,
|
|
})
|
|
}
|
|
|
|
/// The key that decides whether two roster entries are the same member: a
|
|
/// verified account, so an account's several devices count once; or, for a
|
|
/// member with no confirmed account, its device — unique per MLS leaf, so it
|
|
/// never merges with another.
|
|
fn member_key(member: &GroupMember) -> &str {
|
|
member
|
|
.account
|
|
.as_ref()
|
|
.unwrap_or(&member.local_identity)
|
|
.as_str()
|
|
}
|
|
|
|
/// Collapse a roster to one entry per account (keeping the first-seen device as
|
|
/// the account's representative) while leaving account-less members individual,
|
|
/// order preserved.
|
|
fn dedup_members(members: impl IntoIterator<Item = GroupMember>) -> Vec<GroupMember> {
|
|
let mut seen = HashSet::new();
|
|
members
|
|
.into_iter()
|
|
.filter(|member| seen.insert(member_key(member).to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
|
let ConvoOutcome {
|
|
convo_id,
|
|
content,
|
|
members_changed,
|
|
} = outcome;
|
|
let convo_id: Arc<str> = Arc::from(convo_id);
|
|
let mut events = Vec::new();
|
|
if let Some(c) = content
|
|
&& let Ok(sender) = decode_sender(directory, &c.encoded_credential)
|
|
{
|
|
events.push(Event::MessageReceived {
|
|
convo_id: Arc::clone(&convo_id),
|
|
content: c.bytes,
|
|
sender,
|
|
});
|
|
}
|
|
if members_changed {
|
|
events.push(Event::ConversationMembersChanged { convo_id });
|
|
}
|
|
events
|
|
}
|
|
|
|
fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
|
let InboxOutcome {
|
|
new_conversation,
|
|
initial,
|
|
} = outcome;
|
|
let id: Arc<str> = Arc::from(new_conversation.convo_id);
|
|
let mut events = Vec::with_capacity(2);
|
|
events.push(Event::ConversationStarted {
|
|
convo_id: Arc::clone(&id),
|
|
class: new_conversation.class,
|
|
});
|
|
if let Some(c) = initial.and_then(|co| co.content)
|
|
&& let Ok(sender) = decode_sender(directory, &c.encoded_credential)
|
|
{
|
|
events.push(Event::MessageReceived {
|
|
convo_id: Arc::clone(&id),
|
|
content: c.bytes,
|
|
sender,
|
|
});
|
|
}
|
|
events
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod sender_check_tests {
|
|
use std::collections::HashMap;
|
|
|
|
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
|
|
use libchat::IdentId;
|
|
use logos_account::{DeviceSet, SignedDeviceBundle};
|
|
|
|
use super::{
|
|
GroupMember, MessageSender, SenderError, decode_sender, dedup_members, member_key,
|
|
roster_member,
|
|
};
|
|
use crate::delegate::DelegateCredential;
|
|
|
|
/// In-test account → device directory. Holds device id sets keyed by the hex
|
|
/// account key, and can be made to fail to simulate a directory outage.
|
|
#[derive(Debug, Default)]
|
|
struct FakeDir {
|
|
bundles: HashMap<String, Vec<String>>,
|
|
fail: bool,
|
|
}
|
|
|
|
impl FakeDir {
|
|
/// Publish `devices` (verifying keys) as `account`'s device set.
|
|
fn with_devices(account: &Ed25519VerifyingKey, devices: &[&Ed25519VerifyingKey]) -> Self {
|
|
let mut bundles = HashMap::new();
|
|
bundles.insert(
|
|
hex::encode(account.as_ref()),
|
|
devices.iter().map(|d| hex::encode(d.as_ref())).collect(),
|
|
);
|
|
Self {
|
|
bundles,
|
|
fail: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl logos_account::AccountDirectory for FakeDir {
|
|
type Error = &'static str;
|
|
|
|
fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> {
|
|
Ok(())
|
|
}
|
|
|
|
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error> {
|
|
if self.fail {
|
|
return Err("directory unavailable");
|
|
}
|
|
Ok(self
|
|
.bundles
|
|
.get(&hex::encode(account.as_ref()))
|
|
.map(|devices| DeviceSet {
|
|
lamport: 1,
|
|
devices: devices.clone(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn key() -> Ed25519VerifyingKey {
|
|
Ed25519SigningKey::generate().verifying_key()
|
|
}
|
|
|
|
/// Encode a credential exactly as it travels on the wire: the hex of the
|
|
/// serialized TLV, matching the MLS leaf credential's content bytes.
|
|
fn encoded(cred: DelegateCredential) -> Vec<u8> {
|
|
hex::encode(cred.serialize()).into_bytes()
|
|
}
|
|
|
|
fn local_id(k: &Ed25519VerifyingKey) -> IdentId {
|
|
IdentId::new(hex::encode(k.as_ref()))
|
|
}
|
|
|
|
/// The account published a device set that includes the sending device — the
|
|
/// claim checks out, so the message is delivered with a verified account.
|
|
#[test]
|
|
fn verified_sender_surfaces_account_and_device() {
|
|
let account = key();
|
|
let device = key();
|
|
let dir = FakeDir::with_devices(&account, &[&device]);
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
decode_sender(&dir, &encoded(cred)),
|
|
Ok(MessageSender {
|
|
account: Some(local_id(&account)),
|
|
local_identity: local_id(&device),
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The account published a device set that does NOT include the sending
|
|
/// device — a spoofed account claim, so the message is dropped.
|
|
#[test]
|
|
fn contradicted_claim_is_dropped() {
|
|
let account = key();
|
|
let endorsed = key();
|
|
let spoofer = key();
|
|
let dir = FakeDir::with_devices(&account, &[&endorsed]);
|
|
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
decode_sender(&dir, &encoded(cred)),
|
|
Err(SenderError::Unverified)
|
|
);
|
|
}
|
|
|
|
/// A delegate that claims no account surfaces its device but no account.
|
|
#[test]
|
|
fn unassociated_sender_surfaces_device_only() {
|
|
let dir = FakeDir::default();
|
|
let device = key();
|
|
let cred = DelegateCredential::unassociated(&device);
|
|
assert_eq!(
|
|
decode_sender(&dir, &encoded(cred)),
|
|
Ok(MessageSender {
|
|
account: None,
|
|
local_identity: local_id(&device),
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The claimed account has never published a device set — the mapping is
|
|
/// missing, so the message is dropped.
|
|
#[test]
|
|
fn unpublished_account_is_dropped() {
|
|
let account = key();
|
|
let device = key();
|
|
let dir = FakeDir::default(); // nothing published
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
decode_sender(&dir, &encoded(cred)),
|
|
Err(SenderError::Unverified)
|
|
);
|
|
}
|
|
|
|
/// A directory outage leaves the mapping unconfirmed, so the message is
|
|
/// dropped rather than delivered on an unverified claim.
|
|
#[test]
|
|
fn directory_error_is_dropped() {
|
|
let account = key();
|
|
let device = key();
|
|
let dir = FakeDir {
|
|
fail: true,
|
|
..Default::default()
|
|
};
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
decode_sender(&dir, &encoded(cred)),
|
|
Err(SenderError::Unverified)
|
|
);
|
|
}
|
|
|
|
/// An empty credential leaves no sender to attribute, so the message is dropped.
|
|
#[test]
|
|
fn empty_credential_is_dropped() {
|
|
let dir = FakeDir::default();
|
|
assert_eq!(decode_sender(&dir, b""), Err(SenderError::Missing));
|
|
}
|
|
|
|
/// Bytes that aren't a well-formed credential leave the sender's mapping
|
|
/// undeterminable, so the message is dropped.
|
|
#[test]
|
|
fn malformed_credential_is_dropped() {
|
|
let dir = FakeDir::default();
|
|
assert_eq!(decode_sender(&dir, b"not hex"), Err(SenderError::NotHex));
|
|
assert_eq!(
|
|
decode_sender(&dir, hex::encode([0u8; 4]).as_bytes()),
|
|
Err(SenderError::Malformed)
|
|
);
|
|
}
|
|
|
|
/// An account address that isn't a verifying key can't be looked up, so the
|
|
/// claim is unconfirmable and the message is dropped.
|
|
#[test]
|
|
fn non_key_account_address_is_dropped() {
|
|
let dir = FakeDir::default();
|
|
let cred = DelegateCredential::associated(&key(), "user@example.com");
|
|
assert_eq!(
|
|
decode_sender(&dir, &encoded(cred)),
|
|
Err(SenderError::AccountNotAKey)
|
|
);
|
|
}
|
|
|
|
/// A verified account claim surfaces the member's account and device — the
|
|
/// same happy path as a message sender.
|
|
#[test]
|
|
fn roster_verified_member_surfaces_account() {
|
|
let account = key();
|
|
let device = key();
|
|
let dir = FakeDir::with_devices(&account, &[&device]);
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
roster_member(&dir, &encoded(cred)),
|
|
Some(GroupMember {
|
|
account: Some(local_id(&account)),
|
|
local_identity: local_id(&device),
|
|
pending: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// Unlike a message sender, a spoofed account claim does not hide the
|
|
/// member: the device is cryptographically in the group, so it is listed
|
|
/// with no account rather than dropped.
|
|
#[test]
|
|
fn roster_contradicted_claim_lists_device_without_account() {
|
|
let account = key();
|
|
let endorsed = key();
|
|
let spoofer = key();
|
|
let dir = FakeDir::with_devices(&account, &[&endorsed]);
|
|
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
roster_member(&dir, &encoded(cred)),
|
|
Some(GroupMember {
|
|
account: None,
|
|
local_identity: local_id(&spoofer),
|
|
pending: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// A member whose credential claims no account is listed by device only.
|
|
#[test]
|
|
fn roster_unassociated_member_lists_device_without_account() {
|
|
let dir = FakeDir::default();
|
|
let device = key();
|
|
let cred = DelegateCredential::unassociated(&device);
|
|
assert_eq!(
|
|
roster_member(&dir, &encoded(cred)),
|
|
Some(GroupMember {
|
|
account: None,
|
|
local_identity: local_id(&device),
|
|
pending: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// A directory outage leaves the account unconfirmed, but the member stays
|
|
/// on the roster by device (a message would drop here).
|
|
#[test]
|
|
fn roster_directory_outage_lists_device_without_account() {
|
|
let account = key();
|
|
let device = key();
|
|
let dir = FakeDir {
|
|
fail: true,
|
|
..Default::default()
|
|
};
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
|
assert_eq!(
|
|
roster_member(&dir, &encoded(cred)),
|
|
Some(GroupMember {
|
|
account: None,
|
|
local_identity: local_id(&device),
|
|
pending: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// A non-key account address can't be confirmed, so the member is listed by
|
|
/// device without an account.
|
|
#[test]
|
|
fn roster_non_key_account_lists_device_without_account() {
|
|
let dir = FakeDir::default();
|
|
let device = key();
|
|
let cred = DelegateCredential::associated(&device, "user@example.com");
|
|
assert_eq!(
|
|
roster_member(&dir, &encoded(cred)),
|
|
Some(GroupMember {
|
|
account: None,
|
|
local_identity: local_id(&device),
|
|
pending: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The roster collapses an account's several devices into one entry (keeping
|
|
/// the first device seen) while leaving account-less members individual,
|
|
/// order preserved.
|
|
#[test]
|
|
fn dedup_collapses_account_devices_and_keeps_unknowns() {
|
|
let with_account = |account: &str, device: &str| GroupMember {
|
|
account: Some(IdentId::new(account.to_string())),
|
|
local_identity: IdentId::new(device.to_string()),
|
|
pending: false,
|
|
};
|
|
let device_only = |device: &str| GroupMember {
|
|
account: None,
|
|
local_identity: IdentId::new(device.to_string()),
|
|
pending: false,
|
|
};
|
|
let roster = dedup_members(vec![
|
|
with_account("alice", "alice-dev-1"),
|
|
with_account("alice", "alice-dev-2"),
|
|
device_only("orphan-x"),
|
|
with_account("bob", "bob-dev-1"),
|
|
device_only("orphan-y"),
|
|
]);
|
|
let keys: Vec<&str> = roster.iter().map(member_key).collect();
|
|
assert_eq!(keys, ["alice", "orphan-x", "bob", "orphan-y"]);
|
|
// Alice's collapsed entry keeps her first-seen device.
|
|
assert_eq!(roster[0].local_identity.as_str(), "alice-dev-1");
|
|
}
|
|
|
|
/// An account that is both committed and pending collapses to its committed
|
|
/// entry: `group_members` chains committed members first, and dedup keeps
|
|
/// the first entry per account.
|
|
#[test]
|
|
fn dedup_collapses_a_pending_duplicate_into_the_committed_member() {
|
|
let committed = GroupMember {
|
|
account: Some(IdentId::new("alice")),
|
|
local_identity: IdentId::new("alice-dev-1"),
|
|
pending: false,
|
|
};
|
|
let pending = GroupMember {
|
|
account: Some(IdentId::new("alice")),
|
|
local_identity: IdentId::new("alice-dev-2"),
|
|
pending: true,
|
|
};
|
|
assert_eq!(
|
|
dedup_members(vec![committed.clone(), pending]),
|
|
vec![committed]
|
|
);
|
|
}
|
|
}
|