libchat/crates/client/src/client.rs
2026-06-23 10:30:50 -07:00

485 lines
17 KiB
Rust

use std::sync::Arc;
use std::thread::{self, JoinHandle};
use components::{ThreadedWakeupService, WakeupEvent};
use crossbeam_channel::{Receiver, Sender, select};
use crypto::Ed25519VerifyingKey;
use libchat::{
AccountDirectory, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, IdentIdRef,
IdentityProvider, InboxOutcome, Introduction, PayloadOutcome, RegistrationService,
};
use parking_lot::Mutex;
use storage::ChatStore;
use crate::delegate::DelegateCredential;
use crate::errors::ClientError;
use crate::event::Event;
type ClientCore<I, T, R, S> = Core<(I, T, R, ThreadedWakeupService, S)>;
type AccountAddressRef<'a> = &'a str;
type LocalSignerId = IdentId;
/// 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<I, T, R, S>
where
I: IdentityProvider + Send + 'static,
T: Transport + Send + 'static,
R: RegistrationService + 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<I, T, R, S>>>,
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
shutdown: Option<Sender<()>>,
worker: Option<JoinHandle<()>>,
address: String,
}
// -- GenericChatClient
impl<I, T, R, S> ChatClient<I, T, R, S>
where
I: IdentityProvider + Send + 'static,
T: Transport + Send + 'static,
R: RegistrationService + Send + 'static,
S: ChatStore + Send + 'static,
{
pub fn new(
ident: I,
mut transport: T,
reg: R,
storage: S,
) -> 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 core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
Ok(Self::spawn(core, inbound, wakeup_rx))
}
fn spawn(
core: ClientCore<I, T, R, S>,
inbound: Receiver<Vec<u8>>,
wakeup_events: Receiver<WakeupEvent>,
) -> (Self, Receiver<Event>) {
let address = core.ident_id().to_string();
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);
move || worker_loop(core, inbound, wakeup_events, shutdown_rx, event_tx)
});
(
Self {
core,
shutdown: Some(shutdown_tx),
worker: Some(worker),
address,
},
event_rx,
)
}
pub fn addr(&self) -> AccountAddressRef<'_> {
&self.address
}
/// Returns the installation name (identity label) of this client.
pub fn installation_name(&self) -> String {
self.core.lock().installation_name().to_string()
}
/// Produce a serialised introduction bundle for sharing out-of-band.
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ClientError> {
self.core.lock().create_intro_bundle().map_err(Into::into)
}
// 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)
}
/// Parse intro bundle bytes and initiate a private conversation. Outbound
/// envelopes are published by the core. Returns this side's conversation ID.
///
/// This function will be deprecated in the future. Use `create_direct_conversation`
pub fn create_conversation(
&mut self,
intro_bundle: &[u8],
initial_content: &[u8],
) -> Result<ConversationId, ClientError> {
let intro = Introduction::try_from(intro_bundle)?;
self.core
.lock()
.create_private_convo_v1(&intro, initial_content)
.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)
}
// Get signers for a given AccountAddress.
fn signers_from_account(
&self,
account: AccountAddressRef,
) -> Result<Vec<LocalSignerId>, ClientError> {
// Assume Account = LocalSigner until Account is ready
Ok(vec![IdentId::new(account.to_string())])
}
}
impl<I, T, R, S> Drop for ChatClient<I, T, R, S>
where
I: IdentityProvider + Send + 'static,
T: Transport + Send + 'static,
R: RegistrationService + 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<I: IdentityProvider + 'static, T, R, S: ChatStore + 'static>(
core: Arc<Mutex<ClientCore<I, T, R, S>>>,
inbound: Receiver<Vec<u8>>,
wakeup_events: Receiver<WakeupEvent>,
shutdown: Receiver<()>,
event_tx: Sender<Event>,
) where
T: DeliveryService + Send + 'static,
R: RegistrationService + 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, core.account_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
};
if let Err(e) = core.lock().wakeup(&convo_id) {
tracing::warn!("wakeup failed: {e:?}");
}
}
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()
}
/// Whether to surface a received message, given its sender credential checked
/// against the account → device directory (our account store).
///
/// The credential binds a delegate device key to an optional account address.
/// When it claims an account, that account's published device set must include
/// this device — otherwise the account→device mapping is wrong or unconfirmable
/// and the message is dropped (`false`). A credential that claims no account (or
/// no credential at all) asserts no mapping, so it is delivered (`true`).
fn should_deliver(directory: &impl AccountDirectory, encoded: &[u8]) -> bool {
// No credential (e.g. the PrivateV1 placeholder) asserts no account mapping.
if encoded.is_empty() {
return true;
}
let Ok(data) = hex::decode(encoded) else {
tracing::warn!("sender credential is not valid hex; dropping message");
return false;
};
let cred = match DelegateCredential::try_from(data) {
Ok(cred) => cred,
Err(_) => {
tracing::warn!("malformed sender credential; dropping message");
return false;
}
};
let device = hex::encode(cred.delegate_id().as_ref());
// An unassociated delegate asserts no account → device mapping.
let Some(account_addr) = cred.account_addr() else {
return true;
};
let Some(account_key) = account_key_from_hex(account_addr) else {
tracing::warn!(
account_addr,
"sender account address is not a verifying key; dropping message"
);
return false;
};
match directory.fetch(&account_key) {
Ok(Some(set)) if set.devices.iter().any(|d| d == &device) => true,
_ => {
tracing::warn!(account_addr, %device, "account → device mapping is wrong or unconfirmable; dropping message");
false
}
}
}
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
let ConvoOutcome { convo_id, content } = outcome;
content
.filter(|c| should_deliver(directory, &c.encoded_credential))
.map(|c| Event::MessageReceived {
convo_id: Arc::from(convo_id),
content: c.bytes,
})
.into_iter()
.collect()
}
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)
&& should_deliver(directory, &c.encoded_credential)
{
events.push(Event::MessageReceived {
convo_id: Arc::clone(&id),
content: c.bytes,
});
}
events
}
#[cfg(test)]
mod sender_check_tests {
use std::collections::HashMap;
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
use libchat::{DeviceSet, SignedDeviceBundle};
use super::should_deliver;
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 libchat::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()
}
/// The account published a device set that includes the sending device — the
/// claim checks out, so the message is delivered.
#[test]
fn verified_sender_is_delivered() {
let account = key();
let device = key();
let dir = FakeDir::with_devices(&account, &[&device]);
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert!(should_deliver(&dir, &encoded(cred)));
}
/// 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!(!should_deliver(&dir, &encoded(cred)));
}
/// A delegate that claims no account makes no mapping to contradict.
#[test]
fn unassociated_sender_is_delivered() {
let dir = FakeDir::default();
let cred = DelegateCredential::unassociated(&key());
assert!(should_deliver(&dir, &encoded(cred)));
}
/// 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!(!should_deliver(&dir, &encoded(cred)));
}
/// 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!(!should_deliver(&dir, &encoded(cred)));
}
/// No credential at all (e.g. the PrivateV1 placeholder) asserts no account
/// mapping and is delivered.
#[test]
fn empty_credential_is_delivered() {
let dir = FakeDir::default();
assert!(should_deliver(&dir, b""));
}
/// 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!(!should_deliver(&dir, b"not hex"));
assert!(!should_deliver(&dir, hex::encode([0u8; 4]).as_bytes()));
}
/// 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!(!should_deliver(&dir, &encoded(cred)));
}
}