mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-30 13:09:28 +00:00
Merge branch 'main' into seemenkina/mls_clean_up
This commit is contained in:
commit
1c643d0d9b
15
Cargo.lock
generated
15
Cargo.lock
generated
@ -2102,7 +2102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3599,9 +3599,12 @@ dependencies = [
|
||||
"chat-sqlite",
|
||||
"components",
|
||||
"crossbeam-channel",
|
||||
"crypto",
|
||||
"hex",
|
||||
"libchat",
|
||||
"logos-account",
|
||||
"parking_lot",
|
||||
"shared-traits",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
@ -3701,7 +3704,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4987,7 +4990,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5045,7 +5048,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5655,7 +5658,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6353,7 +6356,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::Result;
|
||||
use arboard::Clipboard;
|
||||
use crossbeam_channel::Receiver;
|
||||
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
|
||||
use logos_chat::{ChatClient, EphemeralRegistry, Event, RegistrationService, Transport};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::utils::now;
|
||||
@ -41,7 +41,7 @@ pub struct AppState {
|
||||
pub active_chat: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ChatApp<T: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
||||
pub struct ChatApp<T: Transport, R: RegistrationService = EphemeralRegistry> {
|
||||
pub client: ChatClient<T, R>,
|
||||
events: Receiver<Event>,
|
||||
pub state: AppState,
|
||||
@ -55,7 +55,7 @@ pub struct ChatApp<T: DeliveryService, R: RegistrationService = EphemeralRegistr
|
||||
|
||||
impl<T, R> ChatApp<T, R>
|
||||
where
|
||||
T: DeliveryService + Send + 'static,
|
||||
T: Transport,
|
||||
R: RegistrationService + Send + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
|
||||
@ -8,9 +8,7 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use crossbeam_channel::Receiver;
|
||||
use logos_chat::{
|
||||
ChatClient, DeliveryService, Event, HttpRegistry, RegistrationService, StorageConfig, Transport,
|
||||
};
|
||||
use logos_chat::{ChatClient, Event, HttpRegistry, RegistrationService, StorageConfig, Transport};
|
||||
|
||||
use app::ChatApp;
|
||||
|
||||
@ -132,7 +130,7 @@ fn run<T: Transport>(transport: T, cli: &Cli) -> Result<()> {
|
||||
|
||||
fn launch_tui<T, R>(client: ChatClient<T, R>, events: Receiver<Event>, cli: &Cli) -> Result<()>
|
||||
where
|
||||
T: DeliveryService + Send + 'static,
|
||||
T: Transport,
|
||||
R: RegistrationService + Send + 'static,
|
||||
{
|
||||
let mut app = ChatApp::new(client, events, &cli.name, &cli.data)?;
|
||||
@ -213,7 +211,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
|
||||
|
||||
fn run_app<T, R>(terminal: &mut ui::Tui, app: &mut ChatApp<T, R>) -> Result<()>
|
||||
where
|
||||
T: DeliveryService + Send + 'static,
|
||||
T: Transport,
|
||||
R: RegistrationService + Send + 'static,
|
||||
{
|
||||
loop {
|
||||
|
||||
@ -16,7 +16,7 @@ use ratatui::{
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use logos_chat::{DeliveryService, RegistrationService};
|
||||
use logos_chat::{RegistrationService, Transport};
|
||||
|
||||
use crate::app::ChatApp;
|
||||
|
||||
@ -38,7 +38,7 @@ pub fn restore() -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Draw the UI.
|
||||
pub fn draw<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||
pub fn draw<D: Transport, R: RegistrationService + Send + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
) {
|
||||
@ -58,7 +58,7 @@ pub fn draw<D: DeliveryService + Send + 'static, R: RegistrationService + Send +
|
||||
draw_status(frame, app, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_header<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||
fn draw_header<D: Transport, R: RegistrationService + Send + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
@ -85,7 +85,7 @@ fn draw_header<D: DeliveryService + Send + 'static, R: RegistrationService + Sen
|
||||
frame.render_widget(header, area);
|
||||
}
|
||||
|
||||
fn draw_messages<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||
fn draw_messages<D: Transport, R: RegistrationService + Send + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
@ -175,7 +175,7 @@ fn draw_messages<D: DeliveryService + Send + 'static, R: RegistrationService + S
|
||||
frame.render_stateful_widget(messages_widget, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn draw_input<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||
fn draw_input<D: Transport, R: RegistrationService + Send + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
@ -206,7 +206,7 @@ fn draw_input<D: DeliveryService + Send + 'static, R: RegistrationService + Send
|
||||
frame.set_cursor_position((cursor_x, area.y + 1));
|
||||
}
|
||||
|
||||
fn draw_status<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||
fn draw_status<D: Transport, R: RegistrationService + Send + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
@ -220,10 +220,7 @@ fn draw_status<D: DeliveryService + Send + 'static, R: RegistrationService + Sen
|
||||
}
|
||||
|
||||
/// Handle keyboard events.
|
||||
pub fn handle_events<
|
||||
D: DeliveryService + Send + 'static,
|
||||
R: RegistrationService + Send + 'static,
|
||||
>(
|
||||
pub fn handle_events<D: Transport, R: RegistrationService + Send + 'static>(
|
||||
app: &mut ChatApp<D, R>,
|
||||
) -> io::Result<bool> {
|
||||
// Poll for events with a short timeout to allow checking incoming messages
|
||||
|
||||
@ -255,12 +255,15 @@ impl<S: ExternalServices> Convo<S> for GroupV1Convo {
|
||||
.process_message(&cx.mls_provider, protocol_message)
|
||||
.map_err(ChatError::generic)?;
|
||||
|
||||
let cred_bytes = processed.credential().serialized_content().to_vec();
|
||||
|
||||
let content = match processed.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(msg) => {
|
||||
let reliable = ReliablePayload::decode(msg.into_bytes().as_slice())?;
|
||||
cx.causal.on_receive(&self.convo_id, &reliable);
|
||||
Some(Content {
|
||||
bytes: reliable.content.to_vec(),
|
||||
encoded_credential: cred_bytes,
|
||||
})
|
||||
}
|
||||
ProcessedMessageContent::StagedCommitMessage(commit) => {
|
||||
|
||||
@ -487,12 +487,16 @@ impl GroupV2Convo {
|
||||
events.iter().find_map(|evt| match evt {
|
||||
ConversationEvent::AppMessage(AppMessageProto {
|
||||
payload: Some(app_message::Payload::ConversationMessage(cm)),
|
||||
}) => Some(ConvoOutcome {
|
||||
convo_id: self.convo_id.clone(),
|
||||
content: Some(Content {
|
||||
bytes: cm.message.clone(),
|
||||
}),
|
||||
}),
|
||||
}) => {
|
||||
let cred = cm.sender.as_bytes().to_vec();
|
||||
Some(ConvoOutcome {
|
||||
convo_id: self.convo_id.clone(),
|
||||
content: Some(Content {
|
||||
bytes: cm.message.clone(),
|
||||
encoded_credential: cred,
|
||||
}),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -197,6 +197,7 @@ impl PrivateV1Convo {
|
||||
fn handle_content(&self, bytes: Bytes) -> Content {
|
||||
Content {
|
||||
bytes: bytes.into(),
|
||||
encoded_credential: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -153,6 +153,14 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
|
||||
&self.services.store
|
||||
}
|
||||
|
||||
/// The account → device directory (our account store). Used to verify that a
|
||||
/// received message's claimed account actually endorses the sending device
|
||||
/// before the message is surfaced. Exposed as `RegistrationService`, whose
|
||||
/// `AccountDirectory` supertrait provides `fetch`.
|
||||
pub fn account_directory(&self) -> &S::RS {
|
||||
&self.services.registry
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> &Identity {
|
||||
&self.services.identity
|
||||
}
|
||||
|
||||
@ -31,4 +31,4 @@ pub use service_traits::{DeliveryService, RegistrationService, WakeupService};
|
||||
pub use shared_traits::{IdentId, IdentIdRef, IdentityProvider};
|
||||
pub use storage::ConversationKind;
|
||||
pub use types::AddressedEnvelope;
|
||||
pub use utils::hex_trunc;
|
||||
pub use utils::{hex_trunc, trunc};
|
||||
|
||||
@ -13,6 +13,9 @@ use crate::conversation::ConversationId;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Content {
|
||||
pub bytes: Vec<u8>,
|
||||
/// Hex-encoded [`DelegateCredential`] of the sender, if present in the message.
|
||||
/// Empty when the sender did not attach a credential.
|
||||
pub encoded_credential: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@ -71,3 +71,19 @@ pub fn hex_trunc(data: &[u8]) -> String {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trunc(data: &str) -> String {
|
||||
if data.chars().count() <= 8 {
|
||||
return data.to_string();
|
||||
}
|
||||
let head: String = data.chars().take(4).collect();
|
||||
let tail: String = data
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect();
|
||||
format!("{head}..{tail}")
|
||||
}
|
||||
|
||||
@ -11,10 +11,13 @@ crate-type = ["rlib"]
|
||||
chat-sqlite = { workspace = true }
|
||||
components = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
crypto = { workspace = true }
|
||||
libchat = { workspace = true }
|
||||
logos-account = { workspace = true, features = ["dev"]}
|
||||
shared-traits = { workspace = true }
|
||||
|
||||
# External dependencies (sorted)
|
||||
hex = "0.4.3"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
|
||||
@ -3,17 +3,19 @@ use std::thread::{self, JoinHandle};
|
||||
|
||||
use components::{EphemeralRegistry, ThreadedWakeupService, WakeupEvent};
|
||||
use crossbeam_channel::{Receiver, Sender, select};
|
||||
use crypto::Ed25519VerifyingKey;
|
||||
use libchat::{
|
||||
ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId,
|
||||
IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService, StorageConfig,
|
||||
AccountDirectory, ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService,
|
||||
IdentId, IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService,
|
||||
StorageConfig,
|
||||
};
|
||||
use logos_account::TestLogosAccount;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::delegate::{DelegateCredential, DelegateSigner};
|
||||
use crate::errors::ClientError;
|
||||
use crate::event::Event;
|
||||
|
||||
type ClientCore<T, R> = Core<(TestLogosAccount, T, R, ThreadedWakeupService, ChatStorage)>;
|
||||
type ClientCore<T, R> = Core<(DelegateSigner, T, R, ThreadedWakeupService, ChatStorage)>;
|
||||
type AccountAddressRef<'a> = &'a str;
|
||||
type LocalSignerId = IdentId;
|
||||
|
||||
@ -51,13 +53,14 @@ pub struct ChatClient<T: DeliveryService, R: RegistrationService = EphemeralRegi
|
||||
|
||||
impl<T: Transport> ChatClient<T, EphemeralRegistry> {
|
||||
/// Create an in-memory, ephemeral client. Identity is lost on drop.
|
||||
pub fn new(name: impl Into<String>, mut transport: T) -> (Self, Receiver<Event>) {
|
||||
pub fn new(_: impl Into<String>, mut transport: T) -> (Self, Receiver<Event>) {
|
||||
let inbound = transport.inbound();
|
||||
let ident = TestLogosAccount::new(name);
|
||||
let delegate = DelegateSigner::random();
|
||||
|
||||
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
|
||||
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
|
||||
let core = Core::new_with_name(
|
||||
ident,
|
||||
delegate,
|
||||
transport,
|
||||
EphemeralRegistry::new(),
|
||||
wakeup_service,
|
||||
@ -72,17 +75,17 @@ impl<T: Transport> ChatClient<T, EphemeralRegistry> {
|
||||
/// If an identity already exists in storage it is loaded; otherwise a new
|
||||
/// one is created and saved.
|
||||
pub fn open(
|
||||
name: impl Into<String>,
|
||||
_: impl Into<String>,
|
||||
config: StorageConfig,
|
||||
mut transport: T,
|
||||
) -> Result<(Self, Receiver<Event>), ClientError> {
|
||||
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
||||
let inbound = transport.inbound();
|
||||
let ident = TestLogosAccount::new(name);
|
||||
let delegate = DelegateSigner::random();
|
||||
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
|
||||
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
|
||||
let core = Core::new_from_store(
|
||||
ident,
|
||||
delegate,
|
||||
transport,
|
||||
EphemeralRegistry::new(),
|
||||
wakeup_service,
|
||||
@ -96,7 +99,7 @@ impl<T: Transport> ChatClient<T, EphemeralRegistry> {
|
||||
|
||||
impl<T, R> ChatClient<T, R>
|
||||
where
|
||||
T: DeliveryService + Send + 'static,
|
||||
T: Transport + Send + 'static,
|
||||
R: RegistrationService + Send + 'static,
|
||||
{
|
||||
/// Open or create a persistent client with a caller-supplied registration
|
||||
@ -108,7 +111,7 @@ where
|
||||
/// when a real registry is wired in we want each session to publish so
|
||||
/// other clients can fetch it.
|
||||
pub fn open_with_registry(
|
||||
name: impl Into<String>,
|
||||
_: impl Into<String>,
|
||||
config: StorageConfig,
|
||||
mut transport: T,
|
||||
registry: R,
|
||||
@ -118,14 +121,34 @@ where
|
||||
{
|
||||
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
||||
let inbound = transport.inbound();
|
||||
let ident = TestLogosAccount::new(name);
|
||||
let delegate = DelegateSigner::random();
|
||||
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
|
||||
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
|
||||
let mut core = Core::new_from_store(ident, transport, registry, wakeup_service, store)?;
|
||||
let mut core = Core::new_from_store(delegate, transport, registry, wakeup_service, store)?;
|
||||
core.register_keypackage()?;
|
||||
Ok(Self::spawn(core, inbound, wakeup_rx))
|
||||
}
|
||||
|
||||
/// Create a client with ephemeral storage with the provided Transport and RegistrationService.
|
||||
pub fn new_ephemeral(
|
||||
delegate: DelegateSigner,
|
||||
mut transport: T,
|
||||
reg: R,
|
||||
) -> 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(
|
||||
delegate,
|
||||
transport,
|
||||
reg,
|
||||
wakeup_service,
|
||||
ChatStorage::in_memory(),
|
||||
)?;
|
||||
Ok(Self::spawn(core, inbound, wakeup_rx))
|
||||
}
|
||||
|
||||
fn spawn(
|
||||
core: ClientCore<T, R>,
|
||||
inbound: Receiver<Vec<u8>>,
|
||||
@ -247,7 +270,7 @@ fn worker_loop<T, R>(
|
||||
let events = {
|
||||
let mut core = core.lock();
|
||||
match core.handle_payload(&bytes) {
|
||||
Ok(outcome) => events_from_inbound(outcome),
|
||||
Ok(outcome) => events_from_inbound(outcome, core.account_directory()),
|
||||
Err(e) => {
|
||||
tracing::warn!("inbound handle_payload failed: {e:?}");
|
||||
vec![Event::InboundError {
|
||||
@ -279,17 +302,69 @@ fn worker_loop<T, R>(
|
||||
/// 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) -> Vec<Event> {
|
||||
fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
||||
match result {
|
||||
PayloadOutcome::Empty => Vec::new(),
|
||||
PayloadOutcome::Convo(co) => convo_events(co),
|
||||
PayloadOutcome::Inbox(io) => inbox_events(io),
|
||||
PayloadOutcome::Convo(co) => convo_events(co, directory),
|
||||
PayloadOutcome::Inbox(io) => inbox_events(io, directory),
|
||||
}
|
||||
}
|
||||
|
||||
fn convo_events(outcome: ConvoOutcome) -> Vec<Event> {
|
||||
/// 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,
|
||||
@ -298,7 +373,7 @@ fn convo_events(outcome: ConvoOutcome) -> Vec<Event> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
|
||||
fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
||||
let InboxOutcome {
|
||||
new_conversation,
|
||||
initial,
|
||||
@ -309,7 +384,9 @@ 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(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,
|
||||
@ -317,3 +394,150 @@ fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
301
crates/client/src/delegate.rs
Normal file
301
crates/client/src/delegate.rs
Normal file
@ -0,0 +1,301 @@
|
||||
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
|
||||
use libchat::{IdentId, IdentityProvider, trunc};
|
||||
|
||||
use crate::ClientError;
|
||||
|
||||
type AccountAddr = String;
|
||||
|
||||
/// A local signing identity that holds an Ed25519 keypair.
|
||||
///
|
||||
/// Can be standalone (unassociated) or authorized to act on behalf of an account
|
||||
/// via [`DelegateSigner::associate`].
|
||||
pub struct DelegateSigner {
|
||||
signing_key: Ed25519SigningKey,
|
||||
verifying_key: Ed25519VerifyingKey,
|
||||
identifier: IdentId,
|
||||
account_addr: Option<AccountAddr>,
|
||||
}
|
||||
|
||||
impl DelegateSigner {
|
||||
/// Create a new signer with a randomly generated keypair.
|
||||
pub fn random() -> Self {
|
||||
let signing_key = Ed25519SigningKey::generate();
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let identifier = DelegateCredential::unassociated(&verifying_key).into();
|
||||
Self {
|
||||
signing_key,
|
||||
verifying_key,
|
||||
identifier,
|
||||
account_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Associate a DelegateSigner with an Account.
|
||||
pub fn associate(&mut self, account_addr: AccountAddr) {
|
||||
self.identifier =
|
||||
DelegateCredential::associated(&self.verifying_key, account_addr.as_str()).into();
|
||||
self.account_addr = Some(account_addr);
|
||||
}
|
||||
|
||||
pub fn account_addr(&self) -> Option<&str> {
|
||||
self.account_addr.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityProvider for DelegateSigner {
|
||||
fn id(&self) -> libchat::IdentIdRef<'_> {
|
||||
&self.identifier
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
trunc(self.identifier.as_str())
|
||||
}
|
||||
|
||||
fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature {
|
||||
self.signing_key.sign(payload)
|
||||
}
|
||||
|
||||
fn public_key(&self) -> &Ed25519VerifyingKey {
|
||||
&self.verifying_key
|
||||
}
|
||||
}
|
||||
|
||||
/// A credential issued to a delegate key, optionally bound to an account address.
|
||||
///
|
||||
/// Serialized as a TLV byte sequence prefixed with magic bytes `0x23 0x23`.
|
||||
/// A credential without an `account_addr` is *unassociated* — it identifies the
|
||||
/// delegate key but has not yet been linked to an account.
|
||||
#[derive(Debug)]
|
||||
pub struct DelegateCredential {
|
||||
delegate_id: Ed25519VerifyingKey,
|
||||
account_addr: Option<AccountAddr>,
|
||||
}
|
||||
|
||||
impl DelegateCredential {
|
||||
const TAG_DELEGATE_ID: u8 = 0x01;
|
||||
const TAG_ACCOUNT_ADDR: u8 = 0x02;
|
||||
|
||||
pub fn unassociated(delegate: &Ed25519VerifyingKey) -> Self {
|
||||
Self {
|
||||
delegate_id: delegate.clone(),
|
||||
account_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn associated(delegate: &Ed25519VerifyingKey, account: &str) -> Self {
|
||||
Self {
|
||||
delegate_id: delegate.clone(),
|
||||
account_addr: Some(account.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The delegate (device / LocalIdentity) verifying key this credential names.
|
||||
pub fn delegate_id(&self) -> &Ed25519VerifyingKey {
|
||||
&self.delegate_id
|
||||
}
|
||||
|
||||
/// The account this delegate claims to act for, if it is associated. The
|
||||
/// claim is unverified — confirm it against the account directory.
|
||||
pub fn account_addr(&self) -> Option<&str> {
|
||||
self.account_addr.as_deref()
|
||||
}
|
||||
|
||||
pub fn serialize(self) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x23, 0x23]);
|
||||
let key_bytes = self.delegate_id.as_ref();
|
||||
debug_assert!(
|
||||
key_bytes.len() <= 255,
|
||||
"delegate_id too large for 1-byte TLV length"
|
||||
);
|
||||
data.extend_from_slice(&[Self::TAG_DELEGATE_ID, key_bytes.len() as u8]);
|
||||
data.extend_from_slice(key_bytes);
|
||||
if let Some(addr) = self.account_addr {
|
||||
let addr_bytes = addr.as_bytes();
|
||||
debug_assert!(
|
||||
addr_bytes.len() <= 255,
|
||||
"account_addr too large for 1-byte TLV length"
|
||||
);
|
||||
data.extend_from_slice(&[Self::TAG_ACCOUNT_ADDR, addr_bytes.len() as u8]);
|
||||
data.extend_from_slice(addr_bytes);
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DelegateCredential> for Vec<u8> {
|
||||
fn from(value: DelegateCredential) -> Self {
|
||||
value.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec<u8>> for DelegateCredential {
|
||||
type Error = ClientError;
|
||||
|
||||
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
|
||||
if value.get(..2) != Some(&[0x23, 0x23]) {
|
||||
return Err(ClientError::BadlyFormedCredential);
|
||||
}
|
||||
let mut delegate_id = None;
|
||||
let mut account_addr = None;
|
||||
let mut i = 2;
|
||||
while i + 2 <= value.len() {
|
||||
let tag = value[i];
|
||||
let len = value[i + 1] as usize;
|
||||
i += 2;
|
||||
let v = value
|
||||
.get(i..i + len)
|
||||
.ok_or(ClientError::BadlyFormedCredential)?;
|
||||
i += len;
|
||||
match tag {
|
||||
DelegateCredential::TAG_DELEGATE_ID => {
|
||||
let bytes: &[u8; 32] = v
|
||||
.try_into()
|
||||
.map_err(|_| ClientError::BadlyFormedCredential)?;
|
||||
delegate_id = Some(
|
||||
Ed25519VerifyingKey::from_bytes(bytes)
|
||||
.map_err(|_| ClientError::BadlyFormedCredential)?,
|
||||
);
|
||||
}
|
||||
DelegateCredential::TAG_ACCOUNT_ADDR => {
|
||||
account_addr = Some(
|
||||
String::from_utf8(v.to_vec())
|
||||
.map_err(|_| ClientError::BadlyFormedCredential)?,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
delegate_id: delegate_id.ok_or(ClientError::BadlyFormedCredential)?,
|
||||
account_addr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DelegateCredential> for IdentId {
|
||||
fn from(value: DelegateCredential) -> Self {
|
||||
IdentId::new(hex::encode(value.serialize()))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<IdentId> for DelegateCredential {
|
||||
type Error = ClientError;
|
||||
|
||||
fn try_from(value: IdentId) -> Result<Self, Self::Error> {
|
||||
hex::decode(value.as_str())
|
||||
.map_err(|_| ClientError::BadlyFormedCredential)?
|
||||
.try_into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crypto::Ed25519SigningKey;
|
||||
|
||||
fn test_key() -> Ed25519VerifyingKey {
|
||||
Ed25519SigningKey::generate().verifying_key()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_unassociated() {
|
||||
let key = test_key();
|
||||
let bytes = DelegateCredential::unassociated(&key).serialize();
|
||||
let recovered: DelegateCredential = bytes.clone().try_into().unwrap();
|
||||
assert_eq!(recovered.serialize(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_associated() {
|
||||
let key = test_key();
|
||||
let bytes = DelegateCredential::associated(&key, "user@example.com").serialize();
|
||||
let recovered: DelegateCredential = bytes.clone().try_into().unwrap();
|
||||
assert_eq!(recovered.serialize(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ident_id_roundtrip_unassociated() {
|
||||
let key = test_key();
|
||||
let original = DelegateCredential::unassociated(&key).serialize();
|
||||
let ident_id: IdentId = DelegateCredential::unassociated(&key).into();
|
||||
let recovered: DelegateCredential = ident_id.try_into().unwrap();
|
||||
assert_eq!(recovered.serialize(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ident_id_roundtrip_associated() {
|
||||
let key = test_key();
|
||||
let addr = "user@example.com";
|
||||
let original = DelegateCredential::associated(&key, addr).serialize();
|
||||
let ident_id: IdentId = DelegateCredential::associated(&key, addr).into();
|
||||
let recovered: DelegateCredential = ident_id.try_into().unwrap();
|
||||
assert_eq!(recovered.serialize(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_addr_preserved_across_roundtrip() {
|
||||
let key = test_key();
|
||||
let addr = "alice@libchat.example";
|
||||
let recovered: DelegateCredential = DelegateCredential::associated(&key, addr)
|
||||
.serialize()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.account_addr.as_deref(), Some(addr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unassociated_has_no_account_after_roundtrip() {
|
||||
let key = test_key();
|
||||
let recovered: DelegateCredential = DelegateCredential::unassociated(&key)
|
||||
.serialize()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert!(recovered.account_addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_magic_bytes_rejected() {
|
||||
let bytes = vec![0x00, 0x00, 0x01, 0x20];
|
||||
assert!(matches!(
|
||||
DelegateCredential::try_from(bytes),
|
||||
Err(ClientError::BadlyFormedCredential)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_payload_rejected() {
|
||||
// Magic + TAG_DELEGATE_ID + len=32, but only 16 bytes of key data
|
||||
let mut bytes = vec![0x23, 0x23, 0x01, 32];
|
||||
bytes.extend_from_slice(&[0u8; 16]);
|
||||
assert!(matches!(
|
||||
DelegateCredential::try_from(bytes),
|
||||
Err(ClientError::BadlyFormedCredential)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_delegate_id_rejected() {
|
||||
// Valid magic but no TLV fields
|
||||
let bytes = vec![0x23, 0x23];
|
||||
assert!(matches!(
|
||||
DelegateCredential::try_from(bytes),
|
||||
Err(ClientError::BadlyFormedCredential)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_account_addr_rejected() {
|
||||
let key = test_key();
|
||||
// Build a valid credential then corrupt the account_addr bytes
|
||||
let mut bytes = DelegateCredential::unassociated(&key).serialize();
|
||||
// Append a TAG_ACCOUNT_ADDR field with invalid UTF-8
|
||||
bytes.push(DelegateCredential::TAG_ACCOUNT_ADDR);
|
||||
bytes.push(3); // len
|
||||
bytes.extend_from_slice(&[0xFF, 0xFE, 0xFD]); // invalid UTF-8
|
||||
assert!(matches!(
|
||||
DelegateCredential::try_from(bytes),
|
||||
Err(ClientError::BadlyFormedCredential)
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -4,4 +4,6 @@ use libchat::ChatError;
|
||||
pub enum ClientError {
|
||||
#[error(transparent)]
|
||||
Chat(#[from] ChatError),
|
||||
#[error("received credential could not be parsed")]
|
||||
BadlyFormedCredential,
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
mod client;
|
||||
mod delegate;
|
||||
mod delivery_in_process;
|
||||
mod errors;
|
||||
mod event;
|
||||
|
||||
pub use client::{ChatClient, Transport};
|
||||
pub use delegate::DelegateSigner;
|
||||
pub use delivery_in_process::{InProcessDelivery, MessageBus};
|
||||
pub use errors::ClientError;
|
||||
pub use event::Event;
|
||||
|
||||
@ -1,11 +1,32 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use components::EphemeralRegistry;
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use crypto::Ed25519VerifyingKey;
|
||||
use libchat::{AccountDirectory, IdentityProvider, SignedDeviceBundle, encode_bundle_payload};
|
||||
use logos_account::TestLogosAccount;
|
||||
use logos_chat::{
|
||||
AddressedEnvelope, ChatClient, DeliveryService, Event, InProcessDelivery, MessageBus,
|
||||
StorageConfig, Transport,
|
||||
AddressedEnvelope, ChatClient, DelegateSigner, DeliveryService, Event, InProcessDelivery,
|
||||
MessageBus, StorageConfig, Transport,
|
||||
};
|
||||
|
||||
/// Publish a signed device bundle endorsing `device` as a device of `account`,
|
||||
/// so a receiver can verify the sender's account → device mapping.
|
||||
fn publish_device_bundle(
|
||||
reg: &mut EphemeralRegistry,
|
||||
account: &TestLogosAccount,
|
||||
device: &Ed25519VerifyingKey,
|
||||
) {
|
||||
let payload = encode_bundle_payload(0, std::slice::from_ref(device));
|
||||
let signature = account.sign(&payload);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub: account.public_key().clone(),
|
||||
payload,
|
||||
signature,
|
||||
};
|
||||
reg.publish(&bundle).unwrap();
|
||||
}
|
||||
|
||||
/// Block until the next event arrives and matches; panic on timeout/mismatch.
|
||||
fn expect_event<F, T>(events: &Receiver<Event>, label: &str, mut f: F) -> T
|
||||
where
|
||||
@ -17,6 +38,54 @@ where
|
||||
f(event).unwrap_or_else(|other| panic!("expected {label}, got {other:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_v1_integration() {
|
||||
let bus = MessageBus::default();
|
||||
let saro_delivery = InProcessDelivery::new(bus.clone());
|
||||
let raya_delivery = InProcessDelivery::new(bus);
|
||||
|
||||
let mut reg_service = EphemeralRegistry::new();
|
||||
|
||||
// Create accounts and delegates, associate each delegate with its account
|
||||
// address, and publish a device bundle so the receiver can verify the
|
||||
// account → device mapping carried in the sender's credential.
|
||||
let saro_account = TestLogosAccount::new("Saro");
|
||||
let mut saro_delegate = DelegateSigner::random();
|
||||
saro_delegate.associate(hex::encode(saro_account.public_key().as_ref()));
|
||||
publish_device_bundle(&mut reg_service, &saro_account, saro_delegate.public_key());
|
||||
|
||||
let raya_account = TestLogosAccount::new("Raya");
|
||||
let mut raya_delegate = DelegateSigner::random();
|
||||
raya_delegate.associate(hex::encode(raya_account.public_key().as_ref()));
|
||||
publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key());
|
||||
let raya_delegate_id = raya_delegate.id().clone();
|
||||
|
||||
let (mut saro, _saro_events) =
|
||||
ChatClient::new_ephemeral(saro_delegate, saro_delivery, reg_service.clone()).unwrap();
|
||||
let (_raya, raya_events) =
|
||||
ChatClient::new_ephemeral(raya_delegate, raya_delivery, reg_service.clone()).unwrap();
|
||||
|
||||
let convo_id = saro
|
||||
.create_direct_conversation(raya_delegate_id.as_str())
|
||||
.unwrap();
|
||||
|
||||
// The invite payload yields ConversationStarted then MessageReceived.
|
||||
expect_event(&raya_events, "ConversationStarted", |e| match e {
|
||||
Event::ConversationStarted { convo_id, .. } => Ok(convo_id),
|
||||
other => Err(other),
|
||||
});
|
||||
|
||||
saro.send_message(&convo_id, b"Hey from saro")
|
||||
.expect("payload mismatch");
|
||||
expect_event(&raya_events, "MessageReceived", |e| match e {
|
||||
Event::MessageReceived { content, .. } => {
|
||||
assert_eq!(content.as_slice(), b"Hey from saro");
|
||||
Ok(())
|
||||
}
|
||||
other => Err(other),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saro_raya_message_exchange() {
|
||||
let bus = MessageBus::default();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user