diff --git a/Cargo.lock b/Cargo.lock index c5e0c27..523c5f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1341,9 +1341,13 @@ version = "0.1.0" dependencies = [ "crypto", "hex", + "openmls_traits 0.5.0", "rusqlite", + "serde", + "serde_json", "storage", "tempfile", + "thiserror", "zeroize", ] @@ -5533,6 +5537,7 @@ name = "storage" version = "0.1.0" dependencies = [ "crypto", + "openmls_traits 0.5.0", "thiserror", ] diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 534515a..376e1e0 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, ChatStore, Event, IdentityProvider, RegistrationService, Transport}; +use logos_chat::{ChatClient, Event, IdentityProvider, RegistrationService, Transport}; use serde::{Deserialize, Serialize}; use crate::utils::now; @@ -41,14 +41,13 @@ pub struct AppState { pub active_chat: Option, } -pub struct ChatApp +pub struct ChatApp where I: IdentityProvider + Send + 'static, T: Transport, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { - pub client: ChatClient, + pub client: ChatClient, events: Receiver, pub state: AppState, /// Ephemeral command output — not persisted, cleared on chat switch. @@ -59,15 +58,14 @@ where state_path: PathBuf, } -impl ChatApp +impl ChatApp where I: IdentityProvider + Send, T: Transport, R: RegistrationService + Send + 'static, - S: ChatStore + Send, { pub fn new( - client: ChatClient, + client: ChatClient, events: Receiver, user_name: &str, data_dir: &Path, diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index bd12b68..63f3c5c 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; use crossbeam_channel::Receiver; use logos_chat::{ - ChatClient, ChatStore, Event, IdentityProvider, LogosChatClient, RegistrationService, Transport, + ChatClient, Event, IdentityProvider, LogosChatClient, RegistrationService, Transport, }; use components::{EmbeddedP2pDeliveryService, P2pConfig}; @@ -134,8 +134,8 @@ fn run(transport: T, cli: &Cli) -> Result<()> { launch_tui(client, events, cli) } -fn launch_tui( - client: ChatClient, +fn launch_tui( + client: ChatClient, events: Receiver, cli: &Cli, ) -> Result<()> @@ -143,7 +143,6 @@ where I: IdentityProvider + Send, T: Transport, R: RegistrationService + Send + 'static, - S: ChatStore + Send, { let mut app = ChatApp::new(client, events, &cli.name, &cli.data)?; @@ -157,12 +156,11 @@ where result } -fn run_app(terminal: &mut ui::Tui, app: &mut ChatApp) -> Result<()> +fn run_app(terminal: &mut ui::Tui, app: &mut ChatApp) -> Result<()> where I: IdentityProvider + Send, T: Transport, R: RegistrationService + Send + 'static, - S: ChatStore + Send, { loop { app.process_incoming()?; diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index 7dd2241..2b7e6b4 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::{ChatStore, IdentityProvider, RegistrationService, Transport}; +use logos_chat::{IdentityProvider, RegistrationService, Transport}; use crate::app::ChatApp; @@ -38,12 +38,11 @@ pub fn restore() -> io::Result<()> { } /// Draw the UI. -pub fn draw(frame: &mut Frame, app: &ChatApp) +pub fn draw(frame: &mut Frame, app: &ChatApp) where I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { let chunks = Layout::default() .direction(Direction::Vertical) @@ -61,12 +60,11 @@ where draw_status(frame, app, chunks[3]); } -fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) where I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { let title = match app.current_session() { Some(session) => { @@ -90,12 +88,11 @@ where frame.render_widget(header, area); } -fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) where I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { let remote_name = app .current_session() @@ -182,12 +179,11 @@ where frame.render_stateful_widget(messages_widget, area, &mut list_state); } -fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) where I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { // Inner width: area minus borders (2). let inner_width = area.width.saturating_sub(2) as usize; @@ -215,12 +211,11 @@ where frame.set_cursor_position((cursor_x, area.y + 1)); } -fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) where I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { let status = Paragraph::new(app.status.as_str()) .style(Style::default().fg(Color::Gray)) @@ -231,12 +226,11 @@ where } /// Handle keyboard events. -pub fn handle_events(app: &mut ChatApp) -> io::Result +pub fn handle_events(app: &mut ChatApp) -> io::Result where I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, { // Poll for events with a short timeout to allow checking incoming messages if event::poll(std::time::Duration::from_millis(100))? diff --git a/core/account/src/account.rs b/core/account/src/account.rs index db2959b..316d354 100644 --- a/core/account/src/account.rs +++ b/core/account/src/account.rs @@ -6,6 +6,10 @@ use libchat::IdentityProvider; /// A Test Focused LogosAccount using a pre-defined identifier. /// The test account is not persisted, and uses a single user provided id. /// This account type should not be used in a production system. +/// +/// `Clone` lets a test reuse the same identity across a simulated restart (a +/// fresh `new` would mint a new random key). +#[derive(Clone)] pub struct TestLogosAccount { id: IdentId, signing_key: Ed25519SigningKey, diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index b562a4a..333decc 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -14,7 +14,7 @@ use tracing::debug; use crate::account_directory::{AccountDirectory, resolve_device_ids}; use crate::conversation::ConversationIdRef; -use crate::inbox_v2::MlsProvider; +use crate::inbox_v2::{MlsPqProvider, MlsProvider}; use crate::service_context::{ExternalServices, ServiceContext}; use crate::utils::{blake2b_hex, hash_size}; @@ -49,7 +49,7 @@ impl GroupV1Convo { pub fn new(cx: &mut ServiceContext) -> Result { let config = Self::mls_create_config(cx); let mls_group = MlsGroup::new( - &cx.mls_provider, + &cx.mls_provider(), &cx.mls_identity, &config, cx.mls_identity.get_credential(), @@ -70,13 +70,16 @@ impl GroupV1Convo { cx: &mut ServiceContext, welcome: Welcome, ) -> Result { - let mls_group = - StagedWelcome::build_from_welcome(&cx.mls_provider, &Self::mls_join_config(), welcome) - .unwrap() - .build() - .unwrap() - .into_group(&cx.mls_provider) - .unwrap(); + let mls_group = StagedWelcome::build_from_welcome( + &cx.mls_provider(), + &Self::mls_join_config(), + welcome, + ) + .unwrap() + .build() + .unwrap() + .into_group(&cx.mls_provider()) + .unwrap(); let convo_id = hex::encode(mls_group.group_id().as_slice()); Self::subscribe(&mut cx.ds, &convo_id)?; @@ -93,7 +96,7 @@ impl GroupV1Convo { convo_id: String, group_id: GroupId, ) -> Result { - let mls_group = MlsGroup::load(cx.mls_provider.storage(), &group_id) + let mls_group = MlsGroup::load(cx.mls_provider().storage(), &group_id) .map_err(ChatError::generic)? .ok_or_else(|| ChatError::NoConvo("mls group not found".into()))?; @@ -116,7 +119,7 @@ impl GroupV1Convo { fn mls_create_config(cx: &mut ServiceContext) -> MlsGroupCreateConfig { MlsGroupCreateConfig::builder() - .ciphersuite(cx.mls_provider.crypto().supported_ciphersuites()[0]) + .ciphersuite(cx.mls_provider().crypto().supported_ciphersuites()[0]) .use_ratchet_tree_extension(true) // This is handy for now, until there is central store for this data .build() } @@ -182,7 +185,7 @@ impl GroupV1Convo { let mls_message_out = self .mls_group - .create_message(&cx.mls_provider, &cx.mls_identity, &wire) + .create_message(&cx.mls_provider(), &cx.mls_identity, &wire) .unwrap(); let msg_bytes = mls_message_out.to_bytes().unwrap(); @@ -271,7 +274,7 @@ impl Convo for GroupV1Convo { let processed = self .mls_group - .process_message(&cx.mls_provider, protocol_message) + .process_message(&cx.mls_provider(), protocol_message) .map_err(ChatError::generic)?; let cred_bytes = processed.credential().serialized_content().to_vec(); @@ -287,7 +290,7 @@ impl Convo for GroupV1Convo { } ProcessedMessageContent::StagedCommitMessage(commit) => { self.mls_group - .merge_staged_commit(&cx.mls_provider, *commit) + .merge_staged_commit(&cx.mls_provider(), *commit) .map_err(ChatError::generic)?; None } @@ -329,25 +332,31 @@ impl GroupConvo for GroupV1Convo { // leaf, so all of a user's installations join the group. let mut keypkgs = Vec::with_capacity(members.len()); for ident in members { - keypkgs.extend(self.key_packages_for_account(ident, &cx.mls_provider, &cx.registry)?); + keypkgs.extend(self.key_packages_for_account( + ident, + &cx.mls_provider(), + &cx.registry, + )?); } let (commit, welcome, _group_info) = self .mls_group .add_members( - &cx.mls_provider, + &cx.mls_provider(), &cx.mls_identity, keypkgs.iter().as_slice(), ) .unwrap(); self.mls_group - .merge_pending_commit(&cx.mls_provider) + .merge_pending_commit(&cx.mls_provider()) .unwrap(); // TODO: (P3) Evaluate privacy/performance implications of an aggregated Welcome for multiple users for account_id in members { - cx.mls_provider + // Built inline (not via `cx.mls_provider()`) so the immutable store + // borrow and the `&mut cx.ds` publish borrow stay disjoint. + MlsPqProvider::new(&cx.crypto, &cx.store) .invite_user(&mut cx.ds, account_id, &welcome)?; } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index e5f7e73..af926de 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -100,7 +100,7 @@ impl GroupV2Convo { let conversation = Conversation::create( &convo_id, &member_id(service_ctx), - &service_ctx.mls_provider, + &service_ctx.mls_provider(), service_ctx.mls_identity.get_credential(), CIPHER_SUITE, &service_ctx.mls_identity, @@ -131,7 +131,7 @@ impl GroupV2Convo { ) -> Result { let Some(conv) = Conversation::join( &member_id(service_ctx), - &service_ctx.mls_provider, + &service_ctx.mls_provider(), &service_ctx.mls_identity, &welcome.welcome_bytes, &welcome.conversation_sync_bytes, @@ -198,7 +198,7 @@ where content: &[u8], ) -> Result<(), ChatError> { self.conversation.send_message( - &service_ctx.mls_provider, + &service_ctx.mls_provider(), &service_ctx.mls_identity, content.to_vec(), )?; @@ -225,13 +225,13 @@ where }; self.conversation.process_inbound( - &service_ctx.mls_provider, + &service_ctx.mls_provider(), &service_ctx.mls_identity, &frame.sender_app_id, &inner, )?; self.conversation - .poll(&service_ctx.mls_provider, &service_ctx.mls_identity); + .poll(&service_ctx.mls_provider(), &service_ctx.mls_identity); let events = self.after_op(service_ctx)?; // route + publish + re-arm, returns events match self.events_to_content(&events) { @@ -247,7 +247,9 @@ where fn wakeup(&mut self, ctx: &mut ServiceContext) -> Result<(), ChatError> { info!(convo = %self.convo_id, "Wakeup"); - let outcome = self.conversation.poll(&ctx.mls_provider, &ctx.mls_identity); + let outcome = self + .conversation + .poll(&ctx.mls_provider(), &ctx.mls_identity); if outcome.leave_requested { // Commit ejected us (or join expired). Real handling - drops // this convo from its map; @@ -280,7 +282,7 @@ where self.pending_invites .push(member.as_str().as_bytes().to_vec()); self.conversation.add_member( - &service_ctx.mls_provider, + &service_ctx.mls_provider(), &service_ctx.mls_identity, member.as_str().as_bytes(), &kp_bytes, diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 7f2d5e4..c4737ef 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -8,12 +8,14 @@ use crate::{ conversation::{Convo, GroupConvo}, errors::ChatError, inbox::Inbox, - inbox_v2::{InboxV2, MlsEphemeralPqProvider, MlsIdentityProvider}, + inbox_v2::{InboxV2, MlsIdentityProvider}, outcomes::{ConvoOutcome, InboxOutcome, PayloadOutcome}, proto::{EncryptedPayload, EnvelopeV1, Message}, }; use crypto::{Identity, PublicKey}; use openmls::group::GroupId; +use openmls_libcrux_crypto::CryptoProvider as LibcruxCryptoProvider; +use openmls_traits::storage::{CURRENT_VERSION, StorageProvider}; use shared_traits::IdentIdRef; use std::collections::HashMap; use std::fmt::Debug; @@ -37,15 +39,17 @@ pub struct Core { cached_convos: HashMap>, } -// Constructors live on the `(DS, RS, CS)` form: `S` can't be inferred backwards -// through `S::DS`, so the bundle is built from the three args here. +// Constructors live on the `(IP, DS, RS, WS, CS)` tuple form: `S` can't be +// inferred backwards through `S::DS`, so the bundle is built from the args here. impl Core<(IP, DS, RS, WS, CS)> where IP: IdentityProvider + 'static, DS: DeliveryService + 'static, RS: RegistrationService + 'static, WS: WakeupService + 'static, - CS: ChatStore + 'static, + CS: ChatStore + + StorageProvider + + 'static, { /// Opens or creates a `Core` with the given storage configuration. /// @@ -114,7 +118,7 @@ where let inbox = Inbox::new(&identity); let ident_id = ident.id().clone(); let mls_identity = MlsIdentityProvider::new(ident); - let mls_provider = MlsEphemeralPqProvider::new().map_err(ChatError::generic)?; + let crypto = LibcruxCryptoProvider::new().map_err(ChatError::generic)?; let causal = CausalHistoryStore::new(); let pq_inbox = InboxV2::new(ident_id); @@ -132,7 +136,7 @@ where registry: registration, store, mls_identity, - mls_provider, + crypto, causal, identity, wakeup_service, diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index e52605f..0d0df18 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -12,7 +12,7 @@ use tracing::info; use tracing::instrument; pub use identity::MlsIdentityProvider; -pub(crate) use mls_provider::MlsEphemeralPqProvider; +pub(crate) use mls_provider::MlsPqProvider; use crate::ChatError; use crate::DeliveryService; @@ -195,7 +195,7 @@ impl InboxV2 { .leaf_node_capabilities(capabilities) .build( CIPHER_SUITE, - &cx.mls_provider, + &cx.mls_provider(), &cx.mls_identity, cx.mls_identity.get_credential(), ) diff --git a/core/conversations/src/inbox_v2/mls_provider.rs b/core/conversations/src/inbox_v2/mls_provider.rs index f55f60f..7f11b8a 100644 --- a/core/conversations/src/inbox_v2/mls_provider.rs +++ b/core/conversations/src/inbox_v2/mls_provider.rs @@ -1,8 +1,7 @@ use openmls::framing::MlsMessageOut; use openmls_libcrux_crypto::CryptoProvider as LibcruxCryptoProvider; -use openmls_memory_storage::MemoryStorage; use openmls_traits::OpenMlsProvider; -use openmls_traits::types::CryptoError; +use openmls_traits::storage::{CURRENT_VERSION, StorageProvider}; use prost::Message; use shared_traits::IdentIdRef; @@ -13,22 +12,23 @@ use super::{ conversation_id_for, delivery_address_for, }; -/// This is a Post-Quantum based MLS provider with in memory storage -pub struct MlsEphemeralPqProvider { - crypto: LibcruxCryptoProvider, - storage: MemoryStorage, +/// Post-Quantum MLS provider: a transient view pairing the libcrux crypto/RNG +/// backend with an OpenMLS [`StorageProvider`], both borrowed. Holding borrows +/// rather than owning lets one store instance serve MLS operations while it is +/// separately owned and mutated as the chat store. Crypto/RNG are always libcrux +/// (PQ). +pub struct MlsPqProvider<'a, St: StorageProvider> { + crypto: &'a LibcruxCryptoProvider, + storage: &'a St, } -impl MlsEphemeralPqProvider { - pub fn new() -> Result { - let crypto = LibcruxCryptoProvider::new()?; - let storage = MemoryStorage::default(); - - Ok(Self { crypto, storage }) +impl<'a, St: StorageProvider> MlsPqProvider<'a, St> { + pub fn new(crypto: &'a LibcruxCryptoProvider, storage: &'a St) -> Self { + Self { crypto, storage } } } -impl MlsProvider for MlsEphemeralPqProvider { +impl> MlsProvider for MlsPqProvider<'_, St> { fn invite_user( &self, ds: &mut DS, @@ -59,20 +59,20 @@ impl MlsProvider for MlsEphemeralPqProvider { } } -impl OpenMlsProvider for MlsEphemeralPqProvider { +impl> OpenMlsProvider for MlsPqProvider<'_, St> { type CryptoProvider = LibcruxCryptoProvider; type RandProvider = LibcruxCryptoProvider; - type StorageProvider = openmls_memory_storage::MemoryStorage; + type StorageProvider = St; fn storage(&self) -> &Self::StorageProvider { - &self.storage + self.storage } fn crypto(&self) -> &Self::CryptoProvider { - &self.crypto + self.crypto } fn rand(&self) -> &Self::RandProvider { - &self.crypto + self.crypto } } diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index f247933..be8d353 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -20,6 +20,7 @@ pub use account_directory::{ }; pub use causal_history::{Frontier, MissingMessage}; pub use chat_sqlite::ChatStorage; +pub use chat_sqlite::MlsStorageError; pub use chat_sqlite::StorageConfig; pub use core::{ConversationId, Core, Introduction}; pub use errors::ChatError; @@ -32,3 +33,14 @@ pub use shared_traits::{IdentId, IdentIdRef, IdentityProvider}; pub use storage::{ChatStore, ConversationKind}; pub use types::AddressedEnvelope; pub use utils::{hex_trunc, trunc}; + +/// OpenMLS storage requirements, re-exported so external providers can implement +/// a durable [`StorageProvider`](openmls_traits::storage::StorageProvider) +/// without depending on `openmls_traits` directly. [`ChatStorage`] is libchat's +/// own durable implementation, and [`ChatStore`] folds this surface in so one +/// store type serves both chat and MLS state. +pub mod mls_storage { + pub use openmls_memory_storage::MemoryStorage; + pub use openmls_traits::OpenMlsProvider; + pub use openmls_traits::storage::{CURRENT_VERSION, Entity, Key, StorageProvider, traits}; +} diff --git a/core/conversations/src/service_context.rs b/core/conversations/src/service_context.rs index eb03210..bf88b40 100644 --- a/core/conversations/src/service_context.rs +++ b/core/conversations/src/service_context.rs @@ -1,22 +1,34 @@ //! Bundles the services a conversation operation needs into one [`ServiceContext`]. use crypto::Identity; +use openmls_libcrux_crypto::CryptoProvider as LibcruxCryptoProvider; +use openmls_traits::storage::{CURRENT_VERSION, StorageProvider}; use storage::ChatStore; use crate::IdentityProvider; use crate::causal_history::CausalHistoryStore; -use crate::inbox_v2::{MlsEphemeralPqProvider, MlsIdentityProvider}; +use crate::inbox_v2::{MlsIdentityProvider, MlsPqProvider}; use crate::service_traits::WakeupService; use crate::{DeliveryService, RegistrationService}; -/// Bundles the external service types (`DS`, `RS`, `CS`) behind one `S`. The -/// `(DS, RS, CS)` tuple impl lets them still be supplied separately. +/// Bundles the external service types behind one `S`; the `(IP, DS, RS, WS, CS)` +/// tuple impl lets them still be supplied separately. `CS` is the single durable +/// store: [`ChatStore`] subsumes the OpenMLS storage surface, so chat state and +/// MLS group state share one type. +/// +/// The extra `StorageProvider` bound pins the store's OpenMLS error to a +/// thread-safe, `'static` `std::error::Error`, which GroupV2/de_mls needs. +/// It's stated here (not on `ChatStore`) because an associated-type bound doesn't +/// elaborate through a supertrait, whereas one on this associated type reaches +/// every `S: ExternalServices` consumer. pub trait ExternalServices { type IP: IdentityProvider; type DS: DeliveryService; type RS: RegistrationService; type WS: WakeupService; - type CS: ChatStore; + type CS: ChatStore + + StorageProvider + + 'static; } impl ExternalServices for (IP, DS, RS, WS, CS) @@ -25,7 +37,9 @@ where DS: DeliveryService, RS: RegistrationService, WS: WakeupService, - CS: ChatStore, + CS: ChatStore + + StorageProvider + + 'static, { type IP = IP; type DS = DS; @@ -40,12 +54,20 @@ pub(crate) struct ServiceContext { pub(crate) registry: S::RS, pub(crate) store: S::CS, pub(crate) mls_identity: MlsIdentityProvider, - pub(crate) mls_provider: MlsEphemeralPqProvider, + pub(crate) crypto: LibcruxCryptoProvider, pub(crate) causal: CausalHistoryStore, pub(crate) identity: Identity, pub(crate) wakeup_service: S::WS, } +impl ServiceContext { + /// A transient MLS provider over the shared chat store and the long-lived + /// crypto backend. Rebuilt per call so `store` stays singly owned. + pub(crate) fn mls_provider(&self) -> MlsPqProvider<'_, S::CS> { + MlsPqProvider::new(&self.crypto, &self.store) + } +} + #[cfg(test)] mod test_support { use super::*; @@ -118,8 +140,12 @@ mod test_support { fn wakeup_in(&mut self, _: std::time::Duration, _: crate::ConversationId) {} } - impl - ServiceContext<(IP, NoopDelivery, NoopRegistration, NoopWakeups, CS)> + impl ServiceContext<(IP, NoopDelivery, NoopRegistration, NoopWakeups, CS)> + where + IP: IdentityProvider, + CS: ChatStore + + StorageProvider + + 'static, { /// Builds a context around a real store, stubbing other services. pub(crate) fn for_test(ident: IP, store: CS) -> Result { @@ -129,7 +155,7 @@ mod test_support { registry: NoopRegistration, store, mls_identity: MlsIdentityProvider::new(ident), - mls_provider: MlsEphemeralPqProvider::new().map_err(ChatError::generic)?, + crypto: LibcruxCryptoProvider::new().map_err(ChatError::generic)?, causal: CausalHistoryStore::new(), identity: Identity::new(name), wakeup_service: NoopWakeups {}, diff --git a/core/integration_tests_core/src/test_client.rs b/core/integration_tests_core/src/test_client.rs index 3843f93..66510ec 100644 --- a/core/integration_tests_core/src/test_client.rs +++ b/core/integration_tests_core/src/test_client.rs @@ -1,3 +1,4 @@ +use chat_sqlite::ChatStorage; use libchat::{ConversationId, Core, IdentityProvider, PayloadOutcome}; use logos_account::TestLogosAccount; use shared_traits::IdentId; @@ -7,7 +8,7 @@ use std::ops::{Deref, DerefMut}; use std::time::Duration; use tracing::{info, warn}; -use components::{EphemeralRegistry, LocalBroadcaster, MemStore}; +use components::{EphemeralRegistry, LocalBroadcaster}; use crate::wakeup::{TestWakeupProvider, TestWakeupService, WakeupRecord}; @@ -21,13 +22,12 @@ const RAYA: usize = 1; const PAX: usize = 2; const MIRA: usize = 3; -// type ClientType = CoreClient; type ClientType = Core<( TestLogosAccount, LocalBroadcaster, EphemeralRegistry, WP, - MemStore, + ChatStorage, )>; #[derive(Debug)] @@ -154,9 +154,14 @@ impl TestHarness { let ident = TestLogosAccount::new(Self::names(i)); addresses.insert(i, ident.id().clone()); - let core_client = - ClientType::new_with_name(ident, ds.clone(), rs.clone(), wp, MemStore::new()) - .unwrap(); + let core_client = ClientType::new_with_name( + ident, + ds.clone(), + rs.clone(), + wp, + ChatStorage::in_memory(), + ) + .unwrap(); let client = TestClient::init(core_client); diff --git a/core/integration_tests_core/tests/causal_history.rs b/core/integration_tests_core/tests/causal_history.rs index 4757d55..95f4435 100644 --- a/core/integration_tests_core/tests/causal_history.rs +++ b/core/integration_tests_core/tests/causal_history.rs @@ -6,7 +6,8 @@ use std::ops::{Deref, DerefMut}; -use components::{EphemeralRegistry, LocalBroadcaster, MemStore}; +use chat_sqlite::ChatStorage; +use components::{EphemeralRegistry, LocalBroadcaster}; use libchat::{Core, MissingMessage, WakeupService}; use logos_account::TestLogosAccount; @@ -22,7 +23,7 @@ struct Client { LocalBroadcaster, EphemeralRegistry, NoopWakeupService, - MemStore, + ChatStorage, )>, } @@ -33,7 +34,7 @@ impl Client { LocalBroadcaster, EphemeralRegistry, NoopWakeupService, - MemStore, + ChatStorage, )>, ) -> Self { Client { inner: core } @@ -64,7 +65,7 @@ impl Deref for Client { LocalBroadcaster, EphemeralRegistry, NoopWakeupService, - MemStore, + ChatStorage, )>; fn deref(&self) -> &Self::Target { &self.inner @@ -88,7 +89,7 @@ fn missing_group_message_is_detected() { ds.new_consumer(), rs.clone(), NoopWakeupService {}, - MemStore::new(), + ChatStorage::in_memory(), ) .unwrap(); @@ -98,7 +99,7 @@ fn missing_group_message_is_detected() { ds.clone(), rs.clone(), NoopWakeupService {}, - MemStore::new(), + ChatStorage::in_memory(), ) .unwrap(); diff --git a/core/integration_tests_core/tests/mls_persistence.rs b/core/integration_tests_core/tests/mls_persistence.rs new file mode 100644 index 0000000..181a717 --- /dev/null +++ b/core/integration_tests_core/tests/mls_persistence.rs @@ -0,0 +1,106 @@ +//! Proves a GroupV1 conversation survives a full `Core` restart once MLS state +//! is backed by the durable SQLite `StorageProvider` (issue #112). +//! +//! Saro and Raya exchange over a group; Saro's `Core` is dropped and rebuilt +//! against the same DB files and identity; Saro sends again and Raya still +//! receives it. The post-restart send rehydrates the MLS group via +//! `MlsGroup::load` from durable storage — with the in-memory provider the +//! group would be gone and the send would fail. + +use chat_sqlite::{ChatStorage, StorageConfig}; +use components::{EphemeralRegistry, LocalBroadcaster}; +use libchat::{ConvoOutcome, Core, PayloadOutcome, WakeupService}; +use logos_account::TestLogosAccount; + +#[derive(Debug)] +struct NoopWakeupService {} +impl WakeupService for NoopWakeupService { + fn wakeup_in(&mut self, _: std::time::Duration, _: libchat::ConversationId) {} +} + +type TestCore = Core<( + TestLogosAccount, + LocalBroadcaster, + EphemeralRegistry, + NoopWakeupService, + ChatStorage, +)>; + +/// Builds a `Core` whose single store holds both the conversation metadata and +/// the MLS group state in one on-disk DB, so both persist across a restart. +fn build( + account: TestLogosAccount, + ds: LocalBroadcaster, + rs: EphemeralRegistry, + db_path: &str, +) -> TestCore { + let chat = ChatStorage::new(StorageConfig::File(db_path.to_string())).unwrap(); + Core::new_with_name(account, ds, rs, NoopWakeupService {}, chat).unwrap() +} + +/// Drains everything queued for `core`, returning the bytes of any received +/// conversation messages. +fn drain(core: &mut TestCore) -> Vec> { + let payloads: Vec<_> = { + let ds = core.ds(); + std::iter::from_fn(|| ds.poll()).collect() + }; + let mut received = vec![]; + for data in payloads { + if let PayloadOutcome::Convo(ConvoOutcome { + content: Some(content), + .. + }) = core.handle_payload(&data).unwrap() + { + received.push(content.bytes); + } + } + received +} + +#[test] +fn group_v1_resumes_after_core_restart() { + let dir = tempfile::tempdir().unwrap(); + let saro_db = dir.path().join("saro.db").to_string_lossy().into_owned(); + let raya_db = dir.path().join("raya.db").to_string_lossy().into_owned(); + + let ds = LocalBroadcaster::new(); + let rs = EphemeralRegistry::new(); + + // Cloned so Saro keeps the same identity across the restart (delegate + // persistence is a separate concern; here we hold identity fixed to isolate + // the MLS-storage contribution). + let saro_account = TestLogosAccount::new("saro"); + let raya_account = TestLogosAccount::new("raya"); + + let mut saro = build( + saro_account.clone(), + ds.new_consumer(), + rs.clone(), + &saro_db, + ); + let mut raya = build(raya_account, ds.new_consumer(), rs.clone(), &raya_db); + + // Saro creates a group with Raya; Raya processes the Welcome and joins. + let raya_id = raya.ident_id().clone(); + let convo_id = saro.create_group_convo_v1(&[&raya_id]).unwrap().to_string(); + drain(&mut raya); + + saro.send_content(&convo_id, b"before restart").unwrap(); + assert_eq!(drain(&mut raya), vec![b"before restart".to_vec()]); + + // Restart Saro: drop the Core, rebuild against the same DB file and + // identity. The only thing carrying the group forward is the MLS state + // persisted in `saro.db`. + drop(saro); + let mut saro = build(saro_account, ds.new_consumer(), rs.clone(), &saro_db); + + // The send rehydrates the group via `MlsGroup::load` from durable storage. + saro.send_content(&convo_id, b"after restart").unwrap(); + assert_eq!( + drain(&mut raya), + vec![b"after restart".to_vec()], + "Raya must receive Saro's post-restart message: the MLS group resumed \ + from durable SQLite storage" + ); +} diff --git a/core/sqlite/Cargo.toml b/core/sqlite/Cargo.toml index 4fabf33..f5a3483 100644 --- a/core/sqlite/Cargo.toml +++ b/core/sqlite/Cargo.toml @@ -11,9 +11,13 @@ storage = { workspace = true } # External dependencies (sorted) hex = "0.4.3" +openmls_traits = "0.5.0" rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] } +serde_json = "1.0" +thiserror = "2" zeroize = { version = "1.8.2", features = ["derive"] } [dev-dependencies] # External dependencies (sorted) +serde = { version = "1.0", features = ["derive"] } tempfile = "3" diff --git a/core/sqlite/src/lib.rs b/core/sqlite/src/lib.rs index 8c57bb3..4d737d6 100644 --- a/core/sqlite/src/lib.rs +++ b/core/sqlite/src/lib.rs @@ -3,6 +3,7 @@ mod common; mod errors; mod migrations; +pub mod mls_storage; mod types; use std::collections::HashSet; @@ -22,6 +23,7 @@ use crate::{ }; pub use common::StorageConfig; +pub use mls_storage::MlsStorageError; /// Chat-specific storage operations. /// diff --git a/core/sqlite/src/migrations.rs b/core/sqlite/src/migrations.rs index 439f9fc..2a0a478 100644 --- a/core/sqlite/src/migrations.rs +++ b/core/sqlite/src/migrations.rs @@ -19,6 +19,10 @@ pub fn get_migrations() -> Vec<(&'static str, &'static str)> { "002_ratchet_state", include_str!("migrations/002_ratchet_state.sql"), ), + ( + "003_mls_storage", + include_str!("migrations/003_mls_storage.sql"), + ), ] } diff --git a/core/sqlite/src/migrations/003_mls_storage.sql b/core/sqlite/src/migrations/003_mls_storage.sql new file mode 100644 index 0000000..7a9966e --- /dev/null +++ b/core/sqlite/src/migrations/003_mls_storage.sql @@ -0,0 +1,9 @@ +-- Backing table for the OpenMLS StorageProvider (SqliteMlsStorage). +-- +-- A byte-faithful mirror of openmls_memory_storage's HashMap, Vec>: +-- `key` is `label ++ serde_json(logical_key) ++ version_be`, `value` is the +-- serde_json blob (a single value, or a JSON array for the two list labels). +CREATE TABLE IF NOT EXISTS mls_kv ( + key BLOB PRIMARY KEY, + value BLOB NOT NULL +); diff --git a/core/sqlite/src/mls_storage.rs b/core/sqlite/src/mls_storage.rs new file mode 100644 index 0000000..6fce9a2 --- /dev/null +++ b/core/sqlite/src/mls_storage.rs @@ -0,0 +1,925 @@ +//! The MLS half of [`ChatStorage`](crate::ChatStorage): its OpenMLS +//! [`StorageProvider`] impl, backed by a `mls_kv(key, value)` table in the same +//! SQLite database as the chat schema. +//! +//! A byte-faithful port of `openmls_memory_storage::MemoryStorage`: the same +//! `label ++ serde_json(logical_key) ++ version_be` key derivation and JSON +//! value encoding, but persisted to the table instead of a `HashMap`, so an MLS +//! group's state survives process restarts (`MlsGroup::load` reads it back) +//! where the in-memory provider cannot. +//! +//! Two deviations from the reference impl, both deliberate: +//! - decode paths return `Err` instead of `unwrap`ing, since disk bytes can be +//! corrupt where an in-memory map cannot; +//! - `clear_proposal_queue` deletes queued proposals with the same composite +//! key they were written under (the reference impl uses a bare key and +//! orphans them). + +use openmls_traits::storage::*; +use rusqlite::{OptionalExtension, params}; + +use crate::ChatStorage; + +/// Errors surfaced by [`ChatStorage`]'s MLS [`StorageProvider`] impl as its +/// `Error` type. +#[derive(Debug, thiserror::Error)] +pub enum MlsStorageError { + #[error("sqlite: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("codec: {0}")] + Codec(#[from] serde_json::Error), + #[error("inconsistent storage: {0}")] + Inconsistent(&'static str), +} + +// Raw and typed key/value helpers backing the `StorageProvider` impl below. They +// run on the same connection as the chat schema; the trait's all-`&self` API +// maps onto rusqlite's `&self` methods directly, and a `Core` drives storage +// single-threaded so no external synchronization is needed. +impl ChatStorage { + fn conn(&self) -> &rusqlite::Connection { + self.db.connection() + } + + fn put(&self, storage_key: &[u8], value: &[u8]) -> Result<(), MlsStorageError> { + self.conn().execute( + "INSERT OR REPLACE INTO mls_kv (key, value) VALUES (?1, ?2)", + params![storage_key, value], + )?; + Ok(()) + } + + fn get(&self, storage_key: &[u8]) -> Result>, MlsStorageError> { + Ok(self + .conn() + .query_row( + "SELECT value FROM mls_kv WHERE key = ?1", + params![storage_key], + |row| row.get::<_, Vec>(0), + ) + .optional()?) + } + + fn del(&self, storage_key: &[u8]) -> Result<(), MlsStorageError> { + self.conn() + .execute("DELETE FROM mls_kv WHERE key = ?1", params![storage_key])?; + Ok(()) + } + + // --- typed helpers mirroring MemoryStorage's private helpers --- + + fn write( + &self, + label: &[u8], + key: &[u8], + value: Vec, + ) -> Result<(), MlsStorageError> { + self.put(&build_key_from_vec::(label, key.to_vec()), &value) + } + + fn read>( + &self, + label: &[u8], + key: &[u8], + ) -> Result, MlsStorageError> { + match self.get(&build_key_from_vec::(label, key.to_vec()))? { + Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + None => Ok(None), + } + } + + fn read_list>( + &self, + label: &[u8], + key: &[u8], + ) -> Result, MlsStorageError> { + let list = self.read_raw_list::(label, key)?; + list.iter() + .map(|bytes| serde_json::from_slice(bytes)) + .collect::, _>>() + .map_err(MlsStorageError::from) + } + + /// The raw JSON-array-of-blobs behind a list key (empty when absent). + fn read_raw_list( + &self, + label: &[u8], + key: &[u8], + ) -> Result>, MlsStorageError> { + match self.get(&build_key_from_vec::(label, key.to_vec()))? { + Some(bytes) => Ok(serde_json::from_slice(&bytes)?), + None => Ok(vec![]), + } + } + + fn append( + &self, + label: &[u8], + key: &[u8], + value: Vec, + ) -> Result<(), MlsStorageError> { + let storage_key = build_key_from_vec::(label, key.to_vec()); + let tx = self.conn().unchecked_transaction()?; + let mut list = load_list(&tx, &storage_key)?; + list.push(value); + store_list(&tx, &storage_key, &list)?; + tx.commit()?; + Ok(()) + } + + fn remove_item( + &self, + label: &[u8], + key: &[u8], + value: Vec, + ) -> Result<(), MlsStorageError> { + let storage_key = build_key_from_vec::(label, key.to_vec()); + let tx = self.conn().unchecked_transaction()?; + let mut list = load_list(&tx, &storage_key)?; + if let Some(pos) = list.iter().position(|item| item == &value) { + list.remove(pos); + } + store_list(&tx, &storage_key, &list)?; + tx.commit()?; + Ok(()) + } + + fn delete(&self, label: &[u8], key: &[u8]) -> Result<(), MlsStorageError> { + self.del(&build_key_from_vec::(label, key.to_vec())) + } +} + +fn build_key_from_vec(label: &[u8], key: Vec) -> Vec { + let mut out = label.to_vec(); + out.extend_from_slice(&key); + out.extend_from_slice(&u16::to_be_bytes(V)); + out +} + +fn epoch_key_pairs_id( + group_id: &impl traits::GroupId, + epoch: &impl traits::EpochKey, + leaf_index: u32, +) -> Result, MlsStorageError> { + let mut key = serde_json::to_vec(group_id)?; + key.extend_from_slice(&serde_json::to_vec(epoch)?); + key.extend_from_slice(&serde_json::to_vec(&leaf_index)?); + Ok(key) +} + +fn load_list( + tx: &rusqlite::Transaction<'_>, + storage_key: &[u8], +) -> Result>, MlsStorageError> { + let existing: Option> = tx + .query_row( + "SELECT value FROM mls_kv WHERE key = ?1", + params![storage_key], + |row| row.get::<_, Vec>(0), + ) + .optional()?; + match existing { + Some(bytes) => Ok(serde_json::from_slice(&bytes)?), + None => Ok(vec![]), + } +} + +fn store_list( + tx: &rusqlite::Transaction<'_>, + storage_key: &[u8], + list: &[Vec], +) -> Result<(), MlsStorageError> { + let encoded = serde_json::to_vec(list)?; + tx.execute( + "INSERT OR REPLACE INTO mls_kv (key, value) VALUES (?1, ?2)", + params![storage_key, encoded], + )?; + Ok(()) +} + +const KEY_PACKAGE_LABEL: &[u8] = b"KeyPackage"; +const PSK_LABEL: &[u8] = b"Psk"; +const ENCRYPTION_KEY_PAIR_LABEL: &[u8] = b"EncryptionKeyPair"; +const SIGNATURE_KEY_PAIR_LABEL: &[u8] = b"SignatureKeyPair"; +const EPOCH_KEY_PAIRS_LABEL: &[u8] = b"EpochKeyPairs"; + +const TREE_LABEL: &[u8] = b"Tree"; +const GROUP_CONTEXT_LABEL: &[u8] = b"GroupContext"; +const INTERIM_TRANSCRIPT_HASH_LABEL: &[u8] = b"InterimTranscriptHash"; +const CONFIRMATION_TAG_LABEL: &[u8] = b"ConfirmationTag"; + +const JOIN_CONFIG_LABEL: &[u8] = b"MlsGroupJoinConfig"; +const OWN_LEAF_NODES_LABEL: &[u8] = b"OwnLeafNodes"; +const GROUP_STATE_LABEL: &[u8] = b"GroupState"; +const QUEUED_PROPOSAL_LABEL: &[u8] = b"QueuedProposal"; +const PROPOSAL_QUEUE_REFS_LABEL: &[u8] = b"ProposalQueueRefs"; +const OWN_LEAF_NODE_INDEX_LABEL: &[u8] = b"OwnLeafNodeIndex"; +const EPOCH_SECRETS_LABEL: &[u8] = b"EpochSecrets"; +const RESUMPTION_PSK_STORE_LABEL: &[u8] = b"ResumptionPsk"; +const MESSAGE_SECRETS_LABEL: &[u8] = b"MessageSecrets"; + +impl StorageProvider for ChatStorage { + type Error = MlsStorageError; + + fn queue_proposal< + GroupId: traits::GroupId, + ProposalRef: traits::ProposalRef, + QueuedProposal: traits::QueuedProposal, + >( + &self, + group_id: &GroupId, + proposal_ref: &ProposalRef, + proposal: &QueuedProposal, + ) -> Result<(), Self::Error> { + let key = serde_json::to_vec(&(group_id, proposal_ref))?; + let value = serde_json::to_vec(proposal)?; + self.write::(QUEUED_PROPOSAL_LABEL, &key, value)?; + + let key = serde_json::to_vec(group_id)?; + let value = serde_json::to_vec(proposal_ref)?; + self.append::(PROPOSAL_QUEUE_REFS_LABEL, &key, value) + } + + fn write_tree< + GroupId: traits::GroupId, + TreeSync: traits::TreeSync, + >( + &self, + group_id: &GroupId, + tree: &TreeSync, + ) -> Result<(), Self::Error> { + self.write::( + TREE_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(tree)?, + ) + } + + fn write_interim_transcript_hash< + GroupId: traits::GroupId, + InterimTranscriptHash: traits::InterimTranscriptHash, + >( + &self, + group_id: &GroupId, + interim_transcript_hash: &InterimTranscriptHash, + ) -> Result<(), Self::Error> { + self.write::( + INTERIM_TRANSCRIPT_HASH_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(interim_transcript_hash)?, + ) + } + + fn write_context< + GroupId: traits::GroupId, + GroupContext: traits::GroupContext, + >( + &self, + group_id: &GroupId, + group_context: &GroupContext, + ) -> Result<(), Self::Error> { + self.write::( + GROUP_CONTEXT_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(group_context)?, + ) + } + + fn write_confirmation_tag< + GroupId: traits::GroupId, + ConfirmationTag: traits::ConfirmationTag, + >( + &self, + group_id: &GroupId, + confirmation_tag: &ConfirmationTag, + ) -> Result<(), Self::Error> { + self.write::( + CONFIRMATION_TAG_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(confirmation_tag)?, + ) + } + + fn write_signature_key_pair< + SignaturePublicKey: traits::SignaturePublicKey, + SignatureKeyPair: traits::SignatureKeyPair, + >( + &self, + public_key: &SignaturePublicKey, + signature_key_pair: &SignatureKeyPair, + ) -> Result<(), Self::Error> { + self.write::( + SIGNATURE_KEY_PAIR_LABEL, + &serde_json::to_vec(public_key)?, + serde_json::to_vec(signature_key_pair)?, + ) + } + + fn queued_proposal_refs< + GroupId: traits::GroupId, + ProposalRef: traits::ProposalRef, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read_list::( + PROPOSAL_QUEUE_REFS_LABEL, + &serde_json::to_vec(group_id)?, + ) + } + + fn queued_proposals< + GroupId: traits::GroupId, + ProposalRef: traits::ProposalRef, + QueuedProposal: traits::QueuedProposal, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + let refs: Vec = self.read_list::( + PROPOSAL_QUEUE_REFS_LABEL, + &serde_json::to_vec(group_id)?, + )?; + + refs.into_iter() + .map(|proposal_ref| { + let key = serde_json::to_vec(&(group_id, &proposal_ref))?; + let proposal = self + .read::(QUEUED_PROPOSAL_LABEL, &key)? + .ok_or(MlsStorageError::Inconsistent( + "queued proposal missing for stored reference", + ))?; + Ok((proposal_ref, proposal)) + }) + .collect::, Self::Error>>() + } + + fn tree< + GroupId: traits::GroupId, + TreeSync: traits::TreeSync, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(TREE_LABEL, &serde_json::to_vec(group_id)?) + } + + fn group_context< + GroupId: traits::GroupId, + GroupContext: traits::GroupContext, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(GROUP_CONTEXT_LABEL, &serde_json::to_vec(group_id)?) + } + + fn interim_transcript_hash< + GroupId: traits::GroupId, + InterimTranscriptHash: traits::InterimTranscriptHash, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::( + INTERIM_TRANSCRIPT_HASH_LABEL, + &serde_json::to_vec(group_id)?, + ) + } + + fn confirmation_tag< + GroupId: traits::GroupId, + ConfirmationTag: traits::ConfirmationTag, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(CONFIRMATION_TAG_LABEL, &serde_json::to_vec(group_id)?) + } + + fn signature_key_pair< + SignaturePublicKey: traits::SignaturePublicKey, + SignatureKeyPair: traits::SignatureKeyPair, + >( + &self, + public_key: &SignaturePublicKey, + ) -> Result, Self::Error> { + self.read::(SIGNATURE_KEY_PAIR_LABEL, &serde_json::to_vec(public_key)?) + } + + fn write_key_package< + HashReference: traits::HashReference, + KeyPackage: traits::KeyPackage, + >( + &self, + hash_ref: &HashReference, + key_package: &KeyPackage, + ) -> Result<(), Self::Error> { + self.write::( + KEY_PACKAGE_LABEL, + &serde_json::to_vec(hash_ref)?, + serde_json::to_vec(key_package)?, + ) + } + + fn write_psk< + PskId: traits::PskId, + PskBundle: traits::PskBundle, + >( + &self, + psk_id: &PskId, + psk: &PskBundle, + ) -> Result<(), Self::Error> { + self.write::( + PSK_LABEL, + &serde_json::to_vec(psk_id)?, + serde_json::to_vec(psk)?, + ) + } + + fn write_encryption_key_pair< + EncryptionKey: traits::EncryptionKey, + HpkeKeyPair: traits::HpkeKeyPair, + >( + &self, + public_key: &EncryptionKey, + key_pair: &HpkeKeyPair, + ) -> Result<(), Self::Error> { + self.write::( + ENCRYPTION_KEY_PAIR_LABEL, + &serde_json::to_vec(public_key)?, + serde_json::to_vec(key_pair)?, + ) + } + + fn key_package< + KeyPackageRef: traits::HashReference, + KeyPackage: traits::KeyPackage, + >( + &self, + hash_ref: &KeyPackageRef, + ) -> Result, Self::Error> { + self.read::(KEY_PACKAGE_LABEL, &serde_json::to_vec(hash_ref)?) + } + + fn psk, PskId: traits::PskId>( + &self, + psk_id: &PskId, + ) -> Result, Self::Error> { + self.read::(PSK_LABEL, &serde_json::to_vec(psk_id)?) + } + + fn encryption_key_pair< + HpkeKeyPair: traits::HpkeKeyPair, + EncryptionKey: traits::EncryptionKey, + >( + &self, + public_key: &EncryptionKey, + ) -> Result, Self::Error> { + self.read::(ENCRYPTION_KEY_PAIR_LABEL, &serde_json::to_vec(public_key)?) + } + + fn delete_signature_key_pair< + SignaturePublicKey: traits::SignaturePublicKey, + >( + &self, + public_key: &SignaturePublicKey, + ) -> Result<(), Self::Error> { + self.delete::(SIGNATURE_KEY_PAIR_LABEL, &serde_json::to_vec(public_key)?) + } + + fn delete_encryption_key_pair>( + &self, + public_key: &EncryptionKey, + ) -> Result<(), Self::Error> { + self.delete::(ENCRYPTION_KEY_PAIR_LABEL, &serde_json::to_vec(public_key)?) + } + + fn delete_key_package>( + &self, + hash_ref: &KeyPackageRef, + ) -> Result<(), Self::Error> { + self.delete::(KEY_PACKAGE_LABEL, &serde_json::to_vec(hash_ref)?) + } + + fn delete_psk>( + &self, + psk_id: &PskKey, + ) -> Result<(), Self::Error> { + self.delete::(PSK_LABEL, &serde_json::to_vec(psk_id)?) + } + + fn group_state< + GroupState: traits::GroupState, + GroupId: traits::GroupId, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(GROUP_STATE_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_group_state< + GroupState: traits::GroupState, + GroupId: traits::GroupId, + >( + &self, + group_id: &GroupId, + group_state: &GroupState, + ) -> Result<(), Self::Error> { + self.write::( + GROUP_STATE_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(group_state)?, + ) + } + + fn delete_group_state>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(GROUP_STATE_LABEL, &serde_json::to_vec(group_id)?) + } + + fn message_secrets< + GroupId: traits::GroupId, + MessageSecrets: traits::MessageSecrets, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(MESSAGE_SECRETS_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_message_secrets< + GroupId: traits::GroupId, + MessageSecrets: traits::MessageSecrets, + >( + &self, + group_id: &GroupId, + message_secrets: &MessageSecrets, + ) -> Result<(), Self::Error> { + self.write::( + MESSAGE_SECRETS_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(message_secrets)?, + ) + } + + fn delete_message_secrets>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(MESSAGE_SECRETS_LABEL, &serde_json::to_vec(group_id)?) + } + + fn resumption_psk_store< + GroupId: traits::GroupId, + ResumptionPskStore: traits::ResumptionPskStore, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(RESUMPTION_PSK_STORE_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_resumption_psk_store< + GroupId: traits::GroupId, + ResumptionPskStore: traits::ResumptionPskStore, + >( + &self, + group_id: &GroupId, + resumption_psk_store: &ResumptionPskStore, + ) -> Result<(), Self::Error> { + self.write::( + RESUMPTION_PSK_STORE_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(resumption_psk_store)?, + ) + } + + fn delete_all_resumption_psk_secrets>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(RESUMPTION_PSK_STORE_LABEL, &serde_json::to_vec(group_id)?) + } + + fn own_leaf_index< + GroupId: traits::GroupId, + LeafNodeIndex: traits::LeafNodeIndex, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(OWN_LEAF_NODE_INDEX_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_own_leaf_index< + GroupId: traits::GroupId, + LeafNodeIndex: traits::LeafNodeIndex, + >( + &self, + group_id: &GroupId, + own_leaf_index: &LeafNodeIndex, + ) -> Result<(), Self::Error> { + self.write::( + OWN_LEAF_NODE_INDEX_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(own_leaf_index)?, + ) + } + + fn delete_own_leaf_index>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(OWN_LEAF_NODE_INDEX_LABEL, &serde_json::to_vec(group_id)?) + } + + fn group_epoch_secrets< + GroupId: traits::GroupId, + GroupEpochSecrets: traits::GroupEpochSecrets, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(EPOCH_SECRETS_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_group_epoch_secrets< + GroupId: traits::GroupId, + GroupEpochSecrets: traits::GroupEpochSecrets, + >( + &self, + group_id: &GroupId, + group_epoch_secrets: &GroupEpochSecrets, + ) -> Result<(), Self::Error> { + self.write::( + EPOCH_SECRETS_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(group_epoch_secrets)?, + ) + } + + fn delete_group_epoch_secrets>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(EPOCH_SECRETS_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_encryption_epoch_key_pairs< + GroupId: traits::GroupId, + EpochKey: traits::EpochKey, + HpkeKeyPair: traits::HpkeKeyPair, + >( + &self, + group_id: &GroupId, + epoch: &EpochKey, + leaf_index: u32, + key_pairs: &[HpkeKeyPair], + ) -> Result<(), Self::Error> { + let key = epoch_key_pairs_id(group_id, epoch, leaf_index)?; + let value = serde_json::to_vec(key_pairs)?; + self.write::(EPOCH_KEY_PAIRS_LABEL, &key, value) + } + + fn encryption_epoch_key_pairs< + GroupId: traits::GroupId, + EpochKey: traits::EpochKey, + HpkeKeyPair: traits::HpkeKeyPair, + >( + &self, + group_id: &GroupId, + epoch: &EpochKey, + leaf_index: u32, + ) -> Result, Self::Error> { + let key = epoch_key_pairs_id(group_id, epoch, leaf_index)?; + match self.get(&build_key_from_vec::( + EPOCH_KEY_PAIRS_LABEL, + key, + ))? { + Some(bytes) => Ok(serde_json::from_slice(&bytes)?), + None => Ok(vec![]), + } + } + + fn delete_encryption_epoch_key_pairs< + GroupId: traits::GroupId, + EpochKey: traits::EpochKey, + >( + &self, + group_id: &GroupId, + epoch: &EpochKey, + leaf_index: u32, + ) -> Result<(), Self::Error> { + let key = epoch_key_pairs_id(group_id, epoch, leaf_index)?; + self.delete::(EPOCH_KEY_PAIRS_LABEL, &key) + } + + fn clear_proposal_queue< + GroupId: traits::GroupId, + ProposalRef: traits::ProposalRef, + >( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + let proposal_refs: Vec = self.read_list::( + PROPOSAL_QUEUE_REFS_LABEL, + &serde_json::to_vec(group_id)?, + )?; + + for proposal_ref in proposal_refs { + // Delete under the same composite key `queue_proposal` wrote it with; + // the reference impl uses a bare key here and orphans these rows. + let key = serde_json::to_vec(&(group_id, &proposal_ref))?; + self.delete::(QUEUED_PROPOSAL_LABEL, &key)?; + } + + self.delete::(PROPOSAL_QUEUE_REFS_LABEL, &serde_json::to_vec(group_id)?) + } + + fn mls_group_join_config< + GroupId: traits::GroupId, + MlsGroupJoinConfig: traits::MlsGroupJoinConfig, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read::(JOIN_CONFIG_LABEL, &serde_json::to_vec(group_id)?) + } + + fn write_mls_join_config< + GroupId: traits::GroupId, + MlsGroupJoinConfig: traits::MlsGroupJoinConfig, + >( + &self, + group_id: &GroupId, + config: &MlsGroupJoinConfig, + ) -> Result<(), Self::Error> { + self.write::( + JOIN_CONFIG_LABEL, + &serde_json::to_vec(group_id)?, + serde_json::to_vec(config)?, + ) + } + + fn own_leaf_nodes< + GroupId: traits::GroupId, + LeafNode: traits::LeafNode, + >( + &self, + group_id: &GroupId, + ) -> Result, Self::Error> { + self.read_list::(OWN_LEAF_NODES_LABEL, &serde_json::to_vec(group_id)?) + } + + fn append_own_leaf_node< + GroupId: traits::GroupId, + LeafNode: traits::LeafNode, + >( + &self, + group_id: &GroupId, + leaf_node: &LeafNode, + ) -> Result<(), Self::Error> { + let key = serde_json::to_vec(group_id)?; + let value = serde_json::to_vec(leaf_node)?; + self.append::(OWN_LEAF_NODES_LABEL, &key, value) + } + + fn delete_own_leaf_nodes>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(OWN_LEAF_NODES_LABEL, &serde_json::to_vec(group_id)?) + } + + fn delete_group_config>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(JOIN_CONFIG_LABEL, &serde_json::to_vec(group_id)?) + } + + fn delete_tree>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(TREE_LABEL, &serde_json::to_vec(group_id)?) + } + + fn delete_confirmation_tag>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(CONFIRMATION_TAG_LABEL, &serde_json::to_vec(group_id)?) + } + + fn delete_context>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::(GROUP_CONTEXT_LABEL, &serde_json::to_vec(group_id)?) + } + + fn delete_interim_transcript_hash>( + &self, + group_id: &GroupId, + ) -> Result<(), Self::Error> { + self.delete::( + INTERIM_TRANSCRIPT_HASH_LABEL, + &serde_json::to_vec(group_id)?, + ) + } + + fn remove_proposal< + GroupId: traits::GroupId, + ProposalRef: traits::ProposalRef, + >( + &self, + group_id: &GroupId, + proposal_ref: &ProposalRef, + ) -> Result<(), Self::Error> { + let key = serde_json::to_vec(group_id)?; + let value = serde_json::to_vec(proposal_ref)?; + self.remove_item::(PROPOSAL_QUEUE_REFS_LABEL, &key, value)?; + + let key = serde_json::to_vec(&(group_id, proposal_ref))?; + self.delete::(QUEUED_PROPOSAL_LABEL, &key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::StorageConfig; + + // Minimal Key/Entity newtypes so the KV layer can be exercised without + // dragging in openmls' concrete group types. + #[derive(serde::Serialize)] + struct TestKey(Vec); + impl Key for TestKey {} + impl traits::GroupId for TestKey {} + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Clone)] + struct TestVal(u32); + impl Entity for TestVal {} + impl traits::GroupState for TestVal {} + impl traits::LeafNode for TestVal {} + + fn store() -> ChatStorage { + ChatStorage::in_memory() + } + + #[test] + fn single_value_round_trip() { + let s = store(); + let gid = TestKey(b"group-a".to_vec()); + + assert_eq!(s.group_state::(&gid).unwrap(), None); + s.write_group_state(&gid, &TestVal(7)).unwrap(); + assert_eq!(s.group_state::(&gid).unwrap(), Some(TestVal(7))); + + // Overwrite replaces in place. + s.write_group_state(&gid, &TestVal(9)).unwrap(); + assert_eq!(s.group_state::(&gid).unwrap(), Some(TestVal(9))); + + s.delete_group_state(&gid).unwrap(); + assert_eq!(s.group_state::(&gid).unwrap(), None); + } + + #[test] + fn list_append_and_read() { + let s = store(); + let gid = TestKey(b"group-b".to_vec()); + + assert!(s.own_leaf_nodes::<_, TestVal>(&gid).unwrap().is_empty()); + s.append_own_leaf_node(&gid, &TestVal(1)).unwrap(); + s.append_own_leaf_node(&gid, &TestVal(2)).unwrap(); + + let nodes: Vec = s.own_leaf_nodes(&gid).unwrap(); + assert_eq!(nodes, vec![TestVal(1), TestVal(2)]); + + s.delete_own_leaf_nodes(&gid).unwrap(); + assert!(s.own_leaf_nodes::<_, TestVal>(&gid).unwrap().is_empty()); + } + + #[test] + fn persists_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mls.db").to_str().unwrap().to_string(); + let gid = TestKey(b"group-c".to_vec()); + + { + let s = ChatStorage::new(StorageConfig::File(path.clone())).unwrap(); + s.write_group_state(&gid, &TestVal(42)).unwrap(); + } + + let reopened = ChatStorage::new(StorageConfig::File(path)).unwrap(); + assert_eq!( + reopened.group_state::(&gid).unwrap(), + Some(TestVal(42)) + ); + } +} diff --git a/core/storage/Cargo.toml b/core/storage/Cargo.toml index 0c0775c..9f4edb4 100644 --- a/core/storage/Cargo.toml +++ b/core/storage/Cargo.toml @@ -9,4 +9,5 @@ description = "Shared storage layer for libchat" crypto = { workspace = true } # External dependencies (sorted) +openmls_traits = "0.5.0" thiserror = "2" diff --git a/core/storage/src/store.rs b/core/storage/src/store.rs index d53b16c..61e8c37 100644 --- a/core/storage/src/store.rs +++ b/core/storage/src/store.rs @@ -1,4 +1,5 @@ use crypto::{Identity, PrivateKey}; +use openmls_traits::storage::{CURRENT_VERSION, StorageProvider}; use crate::StorageError; @@ -125,7 +126,23 @@ pub trait RatchetStore { // TODO: (P2) this should be defined in the ConversationType -pub trait ChatStore: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore {} +/// The full durable-storage contract libchat needs: the chat-domain sub-stores +/// plus the OpenMLS [`StorageProvider`], so one type holds both chat state and +/// MLS group state. +pub trait ChatStore: + IdentityStore + + EphemeralKeyStore + + ConversationStore + + RatchetStore + + StorageProvider +{ +} -impl ChatStore for T where T: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore -{} +impl ChatStore for T where + T: IdentityStore + + EphemeralKeyStore + + ConversationStore + + RatchetStore + + StorageProvider +{ +} diff --git a/crates/client/src/builder.rs b/crates/client/src/builder.rs index e093dfe..fdd285b 100644 --- a/crates/client/src/builder.rs +++ b/crates/client/src/builder.rs @@ -1,7 +1,6 @@ use components::EphemeralRegistry; use crossbeam_channel::Receiver; use libchat::{ChatError, ChatStorage, IdentityProvider, RegistrationService, StorageConfig}; -use storage::ChatStore; use crate::Transport; use crate::client::ChatClient; @@ -13,11 +12,14 @@ use crate::event::Event; /// component will be filled in with a sensible default when `build()` is called. pub struct Unset; -pub struct ChatClientBuilder { +pub struct ChatClientBuilder { ident: I, transport: T, registration: R, - storage: S, + /// The durable store; defaults to an ephemeral in-memory `ChatStorage` at + /// `build()` when left unset by [`storage`](Self::storage) or + /// [`storage_config`](Self::storage_config). + storage: Option, } impl Default for ChatClientBuilder { @@ -26,7 +28,7 @@ impl Default for ChatClientBuilder { ident: Unset, transport: Unset, registration: Unset, - storage: Unset, + storage: None, } } } @@ -37,8 +39,8 @@ impl ChatClientBuilder { } } -impl ChatClientBuilder { - pub fn ident(self, ident: NI) -> ChatClientBuilder { +impl ChatClientBuilder { + pub fn ident(self, ident: NI) -> ChatClientBuilder { ChatClientBuilder { ident, transport: self.transport, @@ -47,7 +49,7 @@ impl ChatClientBuilder { } } - pub fn transport(self, transport: NT) -> ChatClientBuilder { + pub fn transport(self, transport: NT) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, transport, @@ -56,7 +58,7 @@ impl ChatClientBuilder { } } - pub fn registration(self, registration: NR) -> ChatClientBuilder { + pub fn registration(self, registration: NR) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, transport: self.transport, @@ -65,151 +67,79 @@ impl ChatClientBuilder { } } - pub fn storage(self, storage: NS) -> ChatClientBuilder { - ChatClientBuilder { - ident: self.ident, - transport: self.transport, - registration: self.registration, - storage, - } + pub fn storage(mut self, storage: ChatStorage) -> Self { + self.storage = Some(storage); + self } - pub fn storage_config(self, config: StorageConfig) -> ChatClientBuilder { + pub fn storage_config(mut self, config: StorageConfig) -> Self { let storage = ChatStorage::new(config) .map_err(ChatError::from) .expect("Storage config file should be valid"); - - ChatClientBuilder { - ident: self.ident, - transport: self.transport, - registration: self.registration, - storage, - } + self.storage = Some(storage); + self } } -type Built = Result<(ChatClient, Receiver), ClientError>; +type Built = Result<(ChatClient, Receiver), ClientError>; -// All four explicitly provided. -impl ChatClientBuilder -where - I: IdentityProvider + Send + 'static, - T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, -{ - pub fn build(self) -> Built { - ChatClient::new(self.ident, self.transport, self.registration, self.storage) - } -} - -// Transport only; I, R, S all default. -impl ChatClientBuilder { - pub fn build(self) -> Built { - ChatClient::new( - DelegateSigner::random(), - self.transport, - EphemeralRegistry::new(), - ChatStorage::in_memory(), - ) - } -} - -// I and T; R and S default. -impl ChatClientBuilder -where - I: IdentityProvider + Send + 'static, - T: Transport + Send + 'static, -{ - pub fn build(self) -> Built { - ChatClient::new( - self.ident, - self.transport, - EphemeralRegistry::new(), - ChatStorage::in_memory(), - ) - } -} - -// T and R; I and S default. -impl ChatClientBuilder -where - T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, -{ - pub fn build(self) -> Built { - ChatClient::new( - DelegateSigner::random(), - self.transport, - self.registration, - ChatStorage::in_memory(), - ) - } -} - -// T and S; I and R default. -impl ChatClientBuilder -where - T: Transport + Send + 'static, - S: ChatStore + Send + 'static, -{ - pub fn build(self) -> Built { - ChatClient::new( - DelegateSigner::random(), - self.transport, - EphemeralRegistry::new(), - self.storage, - ) - } -} - -// I, T, and R; S defaults. -impl ChatClientBuilder +// I and R explicitly provided. +impl ChatClientBuilder where I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, self.transport, self.registration, - ChatStorage::in_memory(), + self.storage.unwrap_or_else(ChatStorage::in_memory), ) } } -// T, R, and S; I defaults. -impl ChatClientBuilder -where - T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, - S: ChatStore + Send + 'static, -{ - pub fn build(self) -> Built { +// Transport only; I and R default. +impl ChatClientBuilder { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), self.transport, - self.registration, - self.storage, + EphemeralRegistry::new(), + self.storage.unwrap_or_else(ChatStorage::in_memory), ) } } -// I, T, and S; R defaults. -impl ChatClientBuilder +// I and T; R defaults. +impl ChatClientBuilder where I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, - S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, self.transport, EphemeralRegistry::new(), - self.storage, + self.storage.unwrap_or_else(ChatStorage::in_memory), + ) + } +} + +// T and R; I defaults. +impl ChatClientBuilder +where + T: Transport + Send + 'static, + R: RegistrationService + Send + 'static, +{ + pub fn build(self) -> Built { + ChatClient::new( + DelegateSigner::random(), + self.transport, + self.registration, + self.storage.unwrap_or_else(ChatStorage::in_memory), ) } } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 13c5449..e48b5a2 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -5,17 +5,19 @@ 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, + AccountDirectory, ChatStorage, 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, MessageSender}; -type ClientCore = Core<(I, T, R, ThreadedWakeupService, S)>; +// The client always persists to `ChatStorage` (a durable SQLite store that holds +// both chat and MLS state, in-memory when the config is ephemeral), so the store +// is fixed rather than a public generic parameter on `ChatClient`. +type ClientCore = Core<(I, T, R, ThreadedWakeupService, ChatStorage)>; type AccountAddressRef<'a> = &'a str; type LocalSignerId = IdentId; @@ -40,16 +42,15 @@ pub trait Transport: DeliveryService + Send + 'static { /// 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 +pub struct ChatClient 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>>, + core: Arc>>, /// Dropped on `Drop` to wake the worker's `select!` and shut it down. shutdown: Option>, worker: Option>, @@ -57,18 +58,17 @@ where } // -- GenericChatClient -impl ChatClient +impl ChatClient 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, + storage: ChatStorage, ) -> Result<(Self, Receiver), ClientError> { let inbound = transport.inbound(); @@ -79,7 +79,7 @@ where } fn spawn( - core: ClientCore, + core: ClientCore, inbound: Receiver>, wakeup_events: Receiver, ) -> (Self, Receiver) { @@ -172,12 +172,11 @@ where } } -impl Drop for ChatClient +impl Drop for ChatClient 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 @@ -192,8 +191,8 @@ where /// 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( - core: Arc>>, +fn worker_loop( + core: Arc>>, inbound: Receiver>, wakeup_events: Receiver, shutdown: Receiver<()>, diff --git a/crates/client/src/logos.rs b/crates/client/src/logos.rs index 152fa61..47cd57c 100644 --- a/crates/client/src/logos.rs +++ b/crates/client/src/logos.rs @@ -12,7 +12,7 @@ //! environment-specific configuration that belong to the binary, not here. use crossbeam_channel::Receiver; -use libchat::{ChatStorage, StorageConfig}; +use libchat::StorageConfig; use crate::ChatClientBuilder; use crate::client::{ChatClient, Transport}; @@ -27,8 +27,9 @@ const REGISTRY_ENDPOINT: &str = "https://devnet.chat-kc.logos.co"; /// A [`ChatClient`] wired to the Logos service stack: a [`DelegateSigner`] /// identity, the HTTP keypackage + account registry ([`HttpRegistry`], which is /// both the keypackage store and the account → device directory), and encrypted -/// [`ChatStorage`]. Only the transport `T` is supplied by the caller. -pub type LogosChatClient = ChatClient; +/// [`ChatStorage`](libchat::ChatStorage). Only the transport `T` is supplied by +/// the caller. +pub type LogosChatClient = ChatClient; impl LogosChatClient where diff --git a/crates/client/tests/saro_and_raya.rs b/crates/client/tests/saro_and_raya.rs index 091083c..92920dd 100644 --- a/crates/client/tests/saro_and_raya.rs +++ b/crates/client/tests/saro_and_raya.rs @@ -33,7 +33,7 @@ fn create_test_client( reg: EphemeralRegistry, ) -> Result< ( - ChatClient, + ChatClient, Receiver, ), logos_chat::ClientError, diff --git a/extensions/components/src/lib.rs b/extensions/components/src/lib.rs index 92ad4a4..fe4057a 100644 --- a/extensions/components/src/lib.rs +++ b/extensions/components/src/lib.rs @@ -1,10 +1,8 @@ mod contact_registry; pub mod delivery; -mod storage; mod wakeup; pub use contact_registry::ephemeral::EphemeralRegistry; pub use contact_registry::http::{HttpRegistry, HttpRegistryError}; pub use delivery::*; -pub use storage::*; pub use wakeup::*; diff --git a/extensions/components/src/storage.rs b/extensions/components/src/storage.rs deleted file mode 100644 index 36bbcbe..0000000 --- a/extensions/components/src/storage.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod in_memory_store; - -pub use in_memory_store::MemStore; diff --git a/extensions/components/src/storage/in_memory_store.rs b/extensions/components/src/storage/in_memory_store.rs deleted file mode 100644 index 2bf84f9..0000000 --- a/extensions/components/src/storage/in_memory_store.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::collections::HashMap; - -use storage::{ - // TODO: (P4) Importable crates need to be prefixed with a project name to avoid conflicts - ConversationMeta, - ConversationStore, - EphemeralKeyStore, - IdentityStore, - RatchetStore, -}; - -/// An Test focused StorageService which holds data in a hashmap -pub struct MemStore { - convos: HashMap, -} - -impl MemStore { - pub fn new() -> Self { - Self { - convos: HashMap::new(), - } - } -} - -impl Default for MemStore { - fn default() -> Self { - Self::new() - } -} - -impl ConversationStore for MemStore { - fn save_conversation( - &mut self, - meta: &storage::ConversationMeta, - ) -> Result<(), storage::StorageError> { - self.convos - .insert(meta.local_convo_id.clone(), meta.clone()); - Ok(()) - } - - fn load_conversation( - &self, - local_convo_id: &str, - ) -> Result, storage::StorageError> { - let a = self.convos.get(local_convo_id).cloned(); - Ok(a) - } - - fn remove_conversation(&mut self, _local_convo_id: &str) -> Result<(), storage::StorageError> { - todo!() - } - - fn load_conversations(&self) -> Result, storage::StorageError> { - Ok(self.convos.values().cloned().collect()) - } - - fn has_conversation(&self, local_convo_id: &str) -> Result { - Ok(self.convos.contains_key(local_convo_id)) - } -} - -impl IdentityStore for MemStore { - fn load_identity(&self) -> Result, storage::StorageError> { - // todo!() - Ok(None) - } - - fn save_identity(&mut self, _identity: &crypto::Identity) -> Result<(), storage::StorageError> { - // todo!() - Ok(()) - } -} - -impl EphemeralKeyStore for MemStore { - fn save_ephemeral_key( - &mut self, - _public_key_hex: &str, - _private_key: &crypto::PrivateKey, - ) -> Result<(), storage::StorageError> { - todo!() - } - - fn load_ephemeral_key( - &self, - _public_key_hex: &str, - ) -> Result, storage::StorageError> { - todo!() - } - - fn remove_ephemeral_key(&mut self, _public_key_hex: &str) -> Result<(), storage::StorageError> { - todo!() - } -} - -impl RatchetStore for MemStore { - fn save_ratchet_state( - &mut self, - _conversation_id: &str, - _state: &storage::RatchetStateRecord, - _skipped_keys: &[storage::SkippedKeyRecord], - ) -> Result<(), storage::StorageError> { - todo!() - } - - fn load_ratchet_state( - &self, - _conversation_id: &str, - ) -> Result { - todo!() - } - - fn load_skipped_keys( - &self, - _conversation_id: &str, - ) -> Result, storage::StorageError> { - todo!() - } - - fn has_ratchet_state(&self, _conversation_id: &str) -> Result { - todo!() - } - - fn delete_ratchet_state( - &mut self, - _conversation_id: &str, - ) -> Result<(), storage::StorageError> { - todo!() - } - - fn cleanup_old_skipped_keys( - &mut self, - _max_age_secs: i64, - ) -> Result { - todo!() - } -}