From d02689c7648bae0a166f9edd03c67c7384653e39 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:38:17 -0700 Subject: [PATCH] Add Delegate Signer and wire into Client (#143) * Add encoded_credential to CovnoOutcome * Add DelegateSigner * Add test for DirectV1 * Add support for undecodable credentials * Add docs * Clean + fixes * clippy fixes * Add unit tests * Update trait bounds --- Cargo.lock | 115 ++----- bin/chat-cli/src/app.rs | 6 +- bin/chat-cli/src/main.rs | 8 +- bin/chat-cli/src/ui.rs | 17 +- .../src/conversation/group_v1.rs | 3 + .../src/conversation/group_v2.rs | 20 +- .../src/conversation/privatev1.rs | 1 + core/conversations/src/lib.rs | 2 +- core/conversations/src/outcomes.rs | 3 + core/conversations/src/utils.rs | 16 + crates/client/Cargo.toml | 3 + crates/client/src/client.rs | 64 +++- crates/client/src/delegate.rs | 290 ++++++++++++++++++ crates/client/src/errors.rs | 2 + crates/client/src/lib.rs | 2 + crates/client/tests/saro_and_raya.rs | 53 +++- 16 files changed, 468 insertions(+), 137 deletions(-) create mode 100644 crates/client/src/delegate.rs diff --git a/Cargo.lock b/Cargo.lock index f4743ec..fd48701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -774,7 +774,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "x11rb", ] @@ -1814,7 +1814,7 @@ dependencies = [ [[package]] name = "de-mls" version = "3.0.0" -source = "git+https://github.com/vacp2p/de-mls?branch=develop#d838e832994fd1d14f624783741bc60b31510fa0" +source = "git+https://github.com/vacp2p/de-mls?branch=develop#2dfcd8c71668856e8d7027968c2c64d87ec7fea7" dependencies = [ "hashgraph-like-consensus", "indexmap 2.14.0", @@ -2104,7 +2104,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]] @@ -3601,9 +3601,12 @@ dependencies = [ "chat-sqlite", "components", "crossbeam-channel", + "crypto", + "hex", "libchat", "logos-account", "parking_lot", + "shared-traits", "tempfile", "thiserror", "tracing", @@ -3703,7 +3706,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]] @@ -4534,7 +4537,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5028,7 +5031,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5086,7 +5089,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5696,7 +5699,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6394,7 +6397,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]] @@ -6468,7 +6471,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6477,16 +6480,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6504,31 +6498,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6537,96 +6514,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.1" diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index f027ff2..239ee7a 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -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, } -pub struct ChatApp { +pub struct ChatApp { pub client: ChatClient, events: Receiver, pub state: AppState, @@ -55,7 +55,7 @@ pub struct ChatApp ChatApp where - T: DeliveryService + Send + 'static, + T: Transport, R: RegistrationService + Send + 'static, { pub fn new( diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index 9db19fb..842faf4 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -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(transport: T, cli: &Cli) -> Result<()> { fn launch_tui(client: ChatClient, events: Receiver, 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(terminal: &mut ui::Tui, app: &mut ChatApp) -> Result<()> where - T: DeliveryService + Send + 'static, + T: Transport, R: RegistrationService + Send + 'static, { loop { diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index 94fa1f5..e1b9813 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -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( +pub fn draw( frame: &mut Frame, app: &ChatApp, ) { @@ -58,7 +58,7 @@ pub fn draw( +fn draw_header( frame: &mut Frame, app: &ChatApp, area: Rect, @@ -85,7 +85,7 @@ fn draw_header( +fn draw_messages( frame: &mut Frame, app: &ChatApp, area: Rect, @@ -175,7 +175,7 @@ fn draw_messages( +fn draw_input( frame: &mut Frame, app: &ChatApp, area: Rect, @@ -206,7 +206,7 @@ fn draw_input( +fn draw_status( frame: &mut Frame, app: &ChatApp, area: Rect, @@ -220,10 +220,7 @@ fn draw_status( +pub fn handle_events( app: &mut ChatApp, ) -> io::Result { // Poll for events with a short timeout to allow checking incoming messages diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index ba59a9e..dbb87f7 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -255,12 +255,15 @@ impl Convo 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) => { diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index 815cf10..5cef07b 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -184,7 +184,7 @@ impl GroupV2Convo { pub fn new( service_ctx: &mut ServiceContext, ) -> Result { - let setup = DemlsSetup::new(service_ctx.mls_identity.display_name())?; + let setup = DemlsSetup::new(service_ctx.mls_identity.id().as_str().to_string())?; let convo_id = rand_string(5); let conversation = Conversation::create(&convo_id, setup.deps())?; let convo = GroupV2Convo { @@ -205,7 +205,7 @@ impl GroupV2Convo { pub fn new_pending( service_ctx: &mut ServiceContext, ) -> Result { - let name = service_ctx.mls_identity.display_name(); + let name = service_ctx.mls_identity.id().as_str().to_string(); let setup = DemlsSetup::new(name.clone())?; let kp = setup.factory.generate_key_package()?; @@ -458,12 +458,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, }) } diff --git a/core/conversations/src/conversation/privatev1.rs b/core/conversations/src/conversation/privatev1.rs index a6e278c..a8a9054 100644 --- a/core/conversations/src/conversation/privatev1.rs +++ b/core/conversations/src/conversation/privatev1.rs @@ -197,6 +197,7 @@ impl PrivateV1Convo { fn handle_content(&self, bytes: Bytes) -> Content { Content { bytes: bytes.into(), + encoded_credential: vec![], } } diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index b701b12..edc093e 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -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}; diff --git a/core/conversations/src/outcomes.rs b/core/conversations/src/outcomes.rs index 3209da8..3f77294 100644 --- a/core/conversations/src/outcomes.rs +++ b/core/conversations/src/outcomes.rs @@ -13,6 +13,9 @@ use crate::conversation::ConversationId; #[derive(Debug, Clone)] pub struct Content { pub bytes: Vec, + /// Hex-encoded [`DelegateCredential`] of the sender, if present in the message. + /// Empty when the sender did not attach a credential. + pub encoded_credential: Vec, } #[derive(Debug, Clone)] diff --git a/core/conversations/src/utils.rs b/core/conversations/src/utils.rs index 3ed1059..10958eb 100644 --- a/core/conversations/src/utils.rs +++ b/core/conversations/src/utils.rs @@ -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::() + .chars() + .rev() + .collect(); + format!("{head}..{tail}") +} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index de97a73..ec34895 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -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" diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 4337bf9..e9b56a2 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -7,13 +7,13 @@ use libchat::{ 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 = Core<(TestLogosAccount, T, R, ThreadedWakeupService, ChatStorage)>; +type ClientCore = Core<(DelegateSigner, T, R, ThreadedWakeupService, ChatStorage)>; type AccountAddressRef<'a> = &'a str; type LocalSignerId = IdentId; @@ -51,13 +51,14 @@ pub struct ChatClient ChatClient { /// Create an in-memory, ephemeral client. Identity is lost on drop. - pub fn new(name: impl Into, mut transport: T) -> (Self, Receiver) { + pub fn new(_: impl Into, mut transport: T) -> (Self, Receiver) { 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 +73,17 @@ impl ChatClient { /// If an identity already exists in storage it is loaded; otherwise a new /// one is created and saved. pub fn open( - name: impl Into, + _: impl Into, config: StorageConfig, mut transport: T, ) -> Result<(Self, Receiver), 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 +97,7 @@ impl ChatClient { impl ChatClient 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 +109,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, + _: impl Into, config: StorageConfig, mut transport: T, registry: R, @@ -118,14 +119,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), 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, inbound: Receiver>, @@ -287,12 +308,24 @@ fn events_from_inbound(result: PayloadOutcome) -> Vec { } } +fn decode_credential(encoded: Vec) { + if let Ok(data) = hex::decode(encoded) + && let Ok(cred) = DelegateCredential::try_from(data) + { + tracing::debug!(?cred, "decoded sender credential"); + // TODO: Integration Point + } +} + fn convo_events(outcome: ConvoOutcome) -> Vec { let ConvoOutcome { convo_id, content } = outcome; content - .map(|c| Event::MessageReceived { - convo_id: Arc::from(convo_id), - content: c.bytes, + .map(|c| { + decode_credential(c.encoded_credential); + Event::MessageReceived { + convo_id: Arc::from(convo_id), + content: c.bytes, + } }) .into_iter() .collect() @@ -310,6 +343,7 @@ fn inbox_events(outcome: InboxOutcome) -> Vec { class: new_conversation.class, }); if let Some(c) = initial.and_then(|co| co.content) { + decode_credential(c.encoded_credential); events.push(Event::MessageReceived { convo_id: Arc::clone(&id), content: c.bytes, diff --git a/crates/client/src/delegate.rs b/crates/client/src/delegate.rs new file mode 100644 index 0000000..4bde122 --- /dev/null +++ b/crates/client/src/delegate.rs @@ -0,0 +1,290 @@ +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, +} + +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, +} + +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()), + } + } + + pub fn serialize(self) -> Vec { + 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 for Vec { + fn from(value: DelegateCredential) -> Self { + value.serialize() + } +} + +impl TryFrom> for DelegateCredential { + type Error = ClientError; + + fn try_from(value: Vec) -> Result { + 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 for IdentId { + fn from(value: DelegateCredential) -> Self { + IdentId::new(hex::encode(value.serialize())) + } +} + +impl TryFrom for DelegateCredential { + type Error = ClientError; + + fn try_from(value: IdentId) -> Result { + 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) + )); + } +} diff --git a/crates/client/src/errors.rs b/crates/client/src/errors.rs index 23f352a..4420ccf 100644 --- a/crates/client/src/errors.rs +++ b/crates/client/src/errors.rs @@ -4,4 +4,6 @@ use libchat::ChatError; pub enum ClientError { #[error(transparent)] Chat(#[from] ChatError), + #[error("received credential could not be parsed")] + BadlyFormedCredential, } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index f66ebba..67cec22 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -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; diff --git a/crates/client/tests/saro_and_raya.rs b/crates/client/tests/saro_and_raya.rs index b588240..9b80652 100644 --- a/crates/client/tests/saro_and_raya.rs +++ b/crates/client/tests/saro_and_raya.rs @@ -1,9 +1,12 @@ use std::time::Duration; +use components::EphemeralRegistry; use crossbeam_channel::{Receiver, Sender}; +use libchat::IdentityProvider; +use logos_account::TestLogosAccount; use logos_chat::{ - AddressedEnvelope, ChatClient, DeliveryService, Event, InProcessDelivery, MessageBus, - StorageConfig, Transport, + AddressedEnvelope, ChatClient, DelegateSigner, DeliveryService, Event, InProcessDelivery, + MessageBus, StorageConfig, Transport, }; /// Block until the next event arrives and matches; panic on timeout/mismatch. @@ -17,6 +20,52 @@ 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 reg_service = EphemeralRegistry::new(); + + // Create Accounts, Deletage and Associate the two. + let saro_account = TestLogosAccount::new("Saro"); + let mut saro_delegate = DelegateSigner::random(); + // TODO: Submit Delegate to Account for auth. + saro_delegate.associate(saro_account.id().to_string()); + + let raya_account = TestLogosAccount::new("Raya"); + let mut raya_delegate = DelegateSigner::random(); + // TODO: Submit Delegate to Account for auth. + raya_delegate.associate(raya_account.id().to_string()); + 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();