From f3aa5d5cf01d6dcc8cba46b5cdb3c9ba9ffeab9f Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 12:53:13 +0800 Subject: [PATCH 01/18] feat: storage for conversations --- Cargo.lock | 2 + conversations/Cargo.toml | 4 + conversations/src/chat.rs | 615 ++++++++++++++++++++ conversations/src/conversation/privatev1.rs | 48 +- conversations/src/errors.rs | 2 + conversations/src/lib.rs | 2 + conversations/src/storage/db.rs | 260 +++++++++ conversations/src/storage/mod.rs | 14 + conversations/src/storage/types.rs | 62 ++ double-ratchets/src/storage/db.rs | 6 + double-ratchets/src/storage/session.rs | 198 +++---- storage/src/sqlite.rs | 16 +- 12 files changed, 1112 insertions(+), 117 deletions(-) create mode 100644 conversations/src/chat.rs create mode 100644 conversations/src/storage/db.rs create mode 100644 conversations/src/storage/mod.rs create mode 100644 conversations/src/storage/types.rs diff --git a/Cargo.lock b/Cargo.lock index bcdb99d..bffaee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,8 @@ dependencies = [ "prost", "rand_core", "safer-ffi", + "storage", + "tempfile", "thiserror", "x25519-dalek", ] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index cdb02a9..b77d9f6 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -18,3 +18,7 @@ rand_core = { version = "0.6" } safer-ffi = "0.1.13" thiserror = "2.0.17" x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] } +storage = { path = "../storage" } + +[dev-dependencies] +tempfile = "3" diff --git a/conversations/src/chat.rs b/conversations/src/chat.rs new file mode 100644 index 0000000..19b9be1 --- /dev/null +++ b/conversations/src/chat.rs @@ -0,0 +1,615 @@ +//! ChatManager with integrated SQLite persistence. +//! +//! This is the main entry point for the conversations API. It handles all +//! storage operations internally - users don't need to interact with storage directly. + +use std::rc::Rc; + +use double_ratchets::storage::RatchetStorage; +use prost::Message; + +use crate::{ + conversation::PrivateV1Convo, + conversation::{Convo, Id}, + errors::ChatError, + identity::Identity, + inbox::{Inbox, Introduction}, + proto, + storage::{ChatRecord, ChatStorage, StorageError}, + types::{AddressedEnvelope, ContentData}, +}; + +// Re-export StorageConfig from storage crate for convenience +pub use storage::StorageConfig; + +/// Error type for ChatManager operations. +#[derive(Debug, thiserror::Error)] +pub enum ChatManagerError { + #[error("chat error: {0}")] + Chat(#[from] ChatError), + + #[error("storage error: {0}")] + Storage(#[from] StorageError), + + #[error("chat not found: {0}")] + ChatNotFound(String), +} + +/// ChatManager is the main entry point for the chat API. +/// +/// It manages identity, inbox, and chats with all state persisted to SQLite. +/// Chats are loaded from storage on each operation - no in-memory caching. +/// Uses a single shared database for both chat metadata and ratchet state. +/// +/// # Example +/// +/// ```ignore +/// // Create a new chat manager with encrypted storage +/// let mut chat = ChatManager::open(StorageConfig::Encrypted { +/// path: "chat.db".into(), +/// key: "my_secret_key".into(), +/// })?; +/// +/// // Get your address to share with others +/// println!("My address: {}", chat.local_address()); +/// +/// // Create an intro bundle to share +/// let intro = chat.create_intro_bundle()?; +/// +/// // Start a chat with someone +/// let (chat_id, envelopes) = chat.start_private_chat(&their_intro, "Hello!")?; +/// // Send envelopes over the network... +/// +/// // Send more messages +/// let envelopes = chat.send_message(&chat_id, b"How are you?")?; +/// ``` +pub struct ChatManager { + identity: Rc, + inbox: Inbox, + /// Storage for chat metadata (identity, inbox keys, chat records). + storage: ChatStorage, + /// Storage config for creating ratchet storage instances. + /// For file/encrypted databases, SQLite handles connection efficiently. + /// For in-memory testing, use SharedInMemory to share data. + storage_config: StorageConfig, +} + +impl ChatManager { + /// Opens or creates a ChatManager with the given storage configuration. + /// + /// If an identity exists in storage, it will be restored. + /// Otherwise, a new identity will be created and saved. + pub fn open(config: StorageConfig) -> Result { + let mut storage = ChatStorage::new(config.clone())?; + + // Load or create identity + let identity = if let Some(identity) = storage.load_identity()? { + identity + } else { + let identity = Identity::new("default"); + storage.save_identity(&identity)?; + identity + }; + + let identity = Rc::new(identity); + let inbox = Inbox::new(Rc::clone(&identity)); + + Ok(Self { + identity, + inbox, + storage, + storage_config: config, + }) + } + + /// Creates a new in-memory ChatManager (for testing). + /// + /// Uses a shared in-memory SQLite database so that multiple storage + /// instances within the same ChatManager share data. + /// + /// The `db_name` should be unique per ChatManager instance to avoid + /// sharing data between different users. + pub fn in_memory(db_name: &str) -> Result { + Self::open(StorageConfig::SharedInMemory(db_name.to_string())) + } + + /// Creates a new RatchetStorage instance using the stored config. + fn create_ratchet_storage(&self) -> Result { + Ok(RatchetStorage::with_config(self.storage_config.clone())?) + } + + /// Load a chat from storage. + fn load_chat(&self, chat_id: &str) -> Result { + let ratchet_storage = self.create_ratchet_storage()?; + if ratchet_storage.exists(chat_id)? { + let base_conv_id = chat_id.parse()?; + Ok(PrivateV1Convo::open(ratchet_storage, base_conv_id)?) + } else if self.storage.chat_exists(chat_id)? { + // Chat metadata exists but no ratchet state - data inconsistency + Err(ChatManagerError::ChatNotFound(format!( + "{} (corrupted: missing ratchet state)", + chat_id + ))) + } else { + Err(ChatManagerError::ChatNotFound(chat_id.to_string())) + } + } + + /// Get the local identity's public address. + /// + /// This address can be shared with others so they can identify you. + pub fn local_address(&self) -> String { + hex::encode(self.identity.public_key().as_bytes()) + } + + /// Create an introduction bundle that can be shared with others. + /// + /// Others can use this bundle to initiate a chat with you. + /// Share it via QR code, link, or any other out-of-band method. + /// + /// The ephemeral key is automatically persisted to storage. + pub fn create_intro_bundle(&mut self) -> Result { + let (pkb, secret) = self.inbox.create_bundle(); + let intro = Introduction::from(pkb); + + // Persist the ephemeral key + let public_key_hex = hex::encode(intro.ephemeral_key.as_bytes()); + self.storage.save_inbox_key(&public_key_hex, &secret)?; + + Ok(intro) + } + + /// Start a new private conversation with someone using their introduction bundle. + /// + /// Returns the chat ID and envelopes that must be delivered to the remote party. + /// The chat state is automatically persisted (via RatchetSession). + pub fn start_private_chat( + &mut self, + remote_bundle: &Introduction, + initial_message: &str, + ) -> Result<(String, Vec), ChatManagerError> { + // Create new storage for this conversation's RatchetSession + let ratchet_storage = self.create_ratchet_storage()?; + + let (convo, payloads) = self.inbox.invite_to_private_convo( + ratchet_storage, + remote_bundle, + initial_message.to_string(), + )?; + + let chat_id = convo.id().to_string(); + + let envelopes: Vec = payloads + .into_iter() + .map(|p| p.to_envelope(chat_id.clone())) + .collect(); + + // Persist chat metadata + let chat_record = ChatRecord::new_private( + chat_id.clone(), + remote_bundle.installation_key, + payloads_delivery_address(&envelopes), + ); + self.storage.save_chat(&chat_record)?; + + // Ratchet state is automatically persisted by RatchetSession + // convo is dropped here - state already saved + + Ok((chat_id, envelopes)) + } + + /// Send a message to an existing chat. + /// + /// Returns envelopes that must be delivered to chat participants. + pub fn send_message( + &mut self, + chat_id: &str, + content: &[u8], + ) -> Result, ChatManagerError> { + // Load chat from storage + let mut chat = self.load_chat(chat_id)?; + + let payloads = chat.send_message(content)?; + + // Ratchet state is automatically persisted by RatchetSession + + let remote_id = chat.remote_id(); + Ok(payloads + .into_iter() + .map(|p| p.to_envelope(remote_id.clone())) + .collect()) + } + + /// Handle an incoming payload from the network. + /// + /// This processes both inbox handshakes (to establish new chats) and + /// messages for existing chats. + /// + /// Returns the decrypted content if successful. + /// Any new chats or state changes are automatically persisted. + pub fn handle_incoming(&mut self, payload: &[u8]) -> Result { + // Try to decode as an envelope + if let Ok(envelope) = proto::EnvelopeV1::decode(payload) { + let chat_id = &envelope.conversation_hint; + + // Check if we have this chat - if so, route to it for decryption + if !chat_id.is_empty() && self.chat_exists(chat_id)? { + return self.receive_message(chat_id, &envelope.payload); + } + + // We don't have this chat - try to handle as inbox handshake + // Pass the conversation_hint so both parties use the same chat ID + return self.handle_inbox_handshake(chat_id, &envelope.payload); + } + + // Not a valid envelope format + Err(ChatManagerError::Chat(ChatError::Protocol( + "invalid envelope format".to_string(), + ))) + } + + /// Handle an inbox handshake to establish a new chat. + fn handle_inbox_handshake( + &mut self, + conversation_hint: &str, + payload: &[u8], + ) -> Result { + // Extract the ephemeral key hex from the payload + let key_hex = Inbox::extract_ephemeral_key_hex(payload)?; + + // Load the ephemeral key from storage + let ephemeral_key = self + .storage + .load_inbox_key(&key_hex)? + .ok_or_else(|| ChatManagerError::Chat(ChatError::UnknownEphemeralKey()))?; + + let ratchet_storage = self.create_ratchet_storage()?; + let result = + self.inbox + .handle_frame(ratchet_storage, conversation_hint, payload, &ephemeral_key)?; + + let chat_id = result.convo.id().to_string(); + + // Persist the new chat metadata + let chat_record = ChatRecord { + chat_id: chat_id.clone(), + chat_type: "private_v1".to_string(), + remote_public_key: Some(result.remote_public_key), + remote_address: hex::encode(result.remote_public_key), + created_at: crate::utils::timestamp_millis() as i64, + }; + self.storage.save_chat(&chat_record)?; + + // Delete the ephemeral key from storage after successful handshake + self.storage.delete_inbox_key(&key_hex)?; + + // Ratchet state is automatically persisted by RatchetSession + // result.convo is dropped here - state already saved + + Ok(ContentData { + conversation_id: chat_id, + data: result.initial_content.unwrap_or_default(), + }) + } + + /// Receive and decrypt a message for an existing chat. + /// + /// The payload should be the raw encrypted payload bytes. + pub fn receive_message( + &mut self, + chat_id: &str, + payload: &[u8], + ) -> Result { + // Load chat from storage + let mut chat = self.load_chat(chat_id)?; + + // Decode and decrypt the payload + let encrypted_payload = proto::EncryptedPayload::decode(payload).map_err(|e| { + ChatManagerError::Chat(ChatError::Protocol(format!("failed to decode: {}", e))) + })?; + + let frame = chat.decrypt(encrypted_payload)?; + let content = PrivateV1Convo::extract_content(&frame).unwrap_or_default(); + + // Ratchet state is automatically persisted by RatchetSession + + Ok(ContentData { + conversation_id: chat_id.to_string(), + data: content, + }) + } + + /// List all chat IDs from storage. + pub fn list_chats(&self) -> Result, ChatManagerError> { + Ok(self.storage.list_chat_ids()?) + } + + /// Check if a chat exists in storage. + pub fn chat_exists(&self, chat_id: &str) -> Result { + Ok(self.storage.chat_exists(chat_id)?) + } + + /// Delete a chat from storage. + pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), ChatManagerError> { + self.storage.delete_chat(chat_id)?; + // Also delete ratchet state from double-ratchets storage + if let Ok(mut ratchet_storage) = self.create_ratchet_storage() { + let _ = ratchet_storage.delete(chat_id); + } + Ok(()) + } +} + +/// Extract delivery address from envelopes (helper function). +fn payloads_delivery_address(envelopes: &[AddressedEnvelope]) -> String { + envelopes + .first() + .map(|e| e.delivery_address.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_chat_manager() { + let manager = ChatManager::in_memory("test1").unwrap(); + assert!(!manager.local_address().is_empty()); + } + + #[test] + fn test_identity_persistence() { + let manager = ChatManager::in_memory("test2").unwrap(); + let address = manager.local_address(); + + // Identity should be persisted + let loaded = manager.storage.load_identity().unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().address(), address); + } + + #[test] + fn test_create_intro_bundle() { + let mut manager = ChatManager::in_memory("test3").unwrap(); + let bundle = manager.create_intro_bundle(); + assert!(bundle.is_ok()); + } + + #[test] + fn test_start_private_chat() { + let mut alice = ChatManager::in_memory("alice1").unwrap(); + let mut bob = ChatManager::in_memory("bob1").unwrap(); + + // Bob creates an intro bundle + let bob_intro = bob.create_intro_bundle().unwrap(); + + // Alice starts a chat with Bob + let result = alice.start_private_chat(&bob_intro, "Hello Bob!"); + assert!(result.is_ok()); + + let (chat_id, envelopes) = result.unwrap(); + assert!(!chat_id.is_empty()); + assert!(!envelopes.is_empty()); + + // Chat should be persisted + let stored = alice.list_chats().unwrap(); + assert!(stored.contains(&chat_id)); + } + + #[test] + fn test_inbox_key_persistence() { + let mut manager = ChatManager::in_memory("test4").unwrap(); + + // Create intro bundle (should persist ephemeral key) + let intro = manager.create_intro_bundle().unwrap(); + let key_hex = hex::encode(intro.ephemeral_key.as_bytes()); + + // Key should be persisted - load it directly + let loaded_key = manager.storage.load_inbox_key(&key_hex).unwrap(); + assert!(loaded_key.is_some()); + } + + #[test] + fn test_chat_exists() { + let mut alice = ChatManager::in_memory("alice2").unwrap(); + let mut bob = ChatManager::in_memory("bob2").unwrap(); + + let bob_intro = bob.create_intro_bundle().unwrap(); + let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); + + // Chat should exist + assert!(alice.chat_exists(&chat_id).unwrap()); + assert!(!alice.chat_exists("nonexistent").unwrap()); + } + + #[test] + fn test_delete_chat() { + let mut alice = ChatManager::in_memory("alice3").unwrap(); + let mut bob = ChatManager::in_memory("bob3").unwrap(); + + let bob_intro = bob.create_intro_bundle().unwrap(); + let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); + + // Delete chat + alice.delete_chat(&chat_id).unwrap(); + + // Chat should no longer exist + assert!(!alice.chat_exists(&chat_id).unwrap()); + assert!(alice.list_chats().unwrap().is_empty()); + } + + #[test] + fn test_ratchet_state_persistence() { + use tempfile::tempdir; + + // Create a temporary directory for the database + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + + let mut bob = ChatManager::in_memory("bob4").unwrap(); + let bob_intro = bob.create_intro_bundle().unwrap(); + + let chat_id; + + // Scope 1: Create chat and send messages + { + let mut alice = + ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) + .unwrap(); + + let result = alice.start_private_chat(&bob_intro, "Message 1").unwrap(); + chat_id = result.0; + + // Send more messages - this advances the ratchet + alice.send_message(&chat_id, b"Message 2").unwrap(); + alice.send_message(&chat_id, b"Message 3").unwrap(); + + // Chat should be in storage + assert!(alice.chat_exists(&chat_id).unwrap()); + } + // alice is dropped here, simulating app close + + // Scope 2: Reopen and verify chat is restored + { + let mut alice2 = + ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) + .unwrap(); + + // Chat should still be in storage + assert!(alice2.list_chats().unwrap().contains(&chat_id)); + + // Send another message - this will load the chat and advance ratchet + let result = alice2.send_message(&chat_id, b"Message 4"); + assert!(result.is_ok(), "Should be able to send after restore"); + } + } + + #[test] + fn test_full_message_roundtrip() { + use tempfile::tempdir; + + // Use temp files instead of in-memory for proper storage sharing + let dir = tempdir().unwrap(); + let alice_db = dir.path().join("alice.db"); + let bob_db = dir.path().join("bob.db"); + + let mut alice = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())).unwrap(); + let mut bob = + ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())).unwrap(); + + // Bob creates an intro bundle and shares it with Alice + let bob_intro = bob.create_intro_bundle().unwrap(); + + // Alice starts a chat with Bob and sends "Hello!" + let (alice_chat_id, envelopes) = + alice.start_private_chat(&bob_intro, "Hello Bob!").unwrap(); + + // Verify Alice has the chat + assert!(alice.chat_exists(&alice_chat_id).unwrap()); + assert_eq!(alice.list_chats().unwrap().len(), 1); + + // Simulate network delivery: Bob receives the envelope + let envelope = envelopes.first().unwrap(); + let content = bob.handle_incoming(&envelope.data).unwrap(); + + // Bob should have received the message + assert_eq!(content.data, b"Hello Bob!"); + + // Bob should now have a chat + assert_eq!(bob.list_chats().unwrap().len(), 1); + let bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); + + // Bob replies to Alice + let bob_reply_envelopes = bob.send_message(&bob_chat_id, b"Hi Alice!").unwrap(); + assert!(!bob_reply_envelopes.is_empty()); + + // Alice receives Bob's reply + let bob_reply = bob_reply_envelopes.first().unwrap(); + let alice_received = alice.handle_incoming(&bob_reply.data).unwrap(); + + assert_eq!(alice_received.data, b"Hi Alice!"); + assert_eq!(alice_received.conversation_id, alice_chat_id); + + // Continue the conversation - Alice sends another message + let alice_envelopes = alice.send_message(&alice_chat_id, b"How are you?").unwrap(); + let alice_msg = alice_envelopes.first().unwrap(); + let bob_received = bob.handle_incoming(&alice_msg.data).unwrap(); + + assert_eq!(bob_received.data, b"How are you?"); + + // Bob replies again + let bob_envelopes = bob + .send_message(&bob_chat_id, b"I'm good, thanks!") + .unwrap(); + let bob_msg = bob_envelopes.first().unwrap(); + let alice_received2 = alice.handle_incoming(&bob_msg.data).unwrap(); + + assert_eq!(alice_received2.data, b"I'm good, thanks!"); + } + + #[test] + fn test_message_persistence_across_sessions() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let alice_db = dir.path().join("alice.db"); + let bob_db = dir.path().join("bob.db"); + + let alice_chat_id; + let bob_chat_id; + let bob_intro; + + // Phase 1: Establish chat + { + let mut alice = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) + .unwrap(); + let mut bob = + ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) + .unwrap(); + + bob_intro = bob.create_intro_bundle().unwrap(); + let (chat_id, envelopes) = alice.start_private_chat(&bob_intro, "Initial").unwrap(); + alice_chat_id = chat_id; + + // Bob receives + let envelope = envelopes.first().unwrap(); + let content = bob.handle_incoming(&envelope.data).unwrap(); + assert_eq!(content.data, b"Initial"); + bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); + } + // Both dropped - simulates app restart + + // Phase 2: Continue conversation after restart + { + let mut alice = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) + .unwrap(); + let mut bob = + ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) + .unwrap(); + + // Both should have persisted chats + assert!(alice.list_chats().unwrap().contains(&alice_chat_id)); + assert!(bob.list_chats().unwrap().contains(&bob_chat_id)); + + // Alice sends a message (chat loads from storage) + let envelopes = alice + .send_message(&alice_chat_id, b"After restart") + .unwrap(); + + // Bob receives (chat loads from storage) + let envelope = envelopes.first().unwrap(); + let content = bob.handle_incoming(&envelope.data).unwrap(); + assert_eq!(content.data, b"After restart"); + + // Bob replies + let bob_envelopes = bob.send_message(&bob_chat_id, b"Still works!").unwrap(); + let bob_msg = bob_envelopes.first().unwrap(); + let alice_received = alice.handle_incoming(&bob_msg.data).unwrap(); + assert_eq!(alice_received.data, b"Still works!"); + } + } +} diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index 0b8042e..b924d3e 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -7,9 +7,12 @@ use chat_proto::logoschat::{ encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption}, }; use crypto::{PrivateKey, PublicKey, SymmetricKey32}; -use double_ratchets::{Header, InstallationKeyPair, RatchetState}; +use double_ratchets::{Header, InstallationKeyPair, RatchetSession, RatchetState, RatchetStorage}; use prost::{Message, bytes::Bytes}; -use std::fmt::Debug; +use std::{ + fmt::{self, Debug, Display, Formatter}, + str::FromStr, +}; use crate::{ conversation::{ChatError, ConversationId, Convo, Id}, @@ -52,10 +55,34 @@ impl BaseConvoId { } } +impl Display for BaseConvoId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +impl FromStr for BaseConvoId { + type Err = ChatError; + + fn from_str(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|_| ChatError::BadParsing("base conversation ID"))?; + + if bytes.len() != 18 { + return Err(ChatError::BadParsing("base conversation ID")); + } + + let mut arr = [0u8; 18]; + arr.copy_from_slice(&bytes); + + Ok(Self(arr)) + } +} + pub struct PrivateV1Convo { local_convo_id: String, remote_convo_id: String, dr_state: RatchetState, + session: Option, } impl PrivateV1Convo { @@ -74,6 +101,7 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, + session: None, } } @@ -93,9 +121,25 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, + session: None, } } + /// Open an existing conversation from storage. + pub fn open(storage: RatchetStorage, base_convo_id: BaseConvoId) -> Result { + let local_convo_id = base_convo_id.id_for_participant(Role::Responder); + let remote_convo_id = base_convo_id.id_for_participant(Role::Initiator); + + let session = RatchetSession::open(storage, &local_convo_id)?; + + Ok(Self { + local_convo_id, + remote_convo_id, + dr_state: session.state().clone(), + session: Some(session), + }) + } + fn encrypt(&mut self, frame: PrivateV1Frame) -> EncryptedPayload { let encoded_bytes = frame.encode_to_vec(); let (cipher_text, header) = self.dr_state.encrypt_message(&encoded_bytes); diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index d551960..1df0e68 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -20,6 +20,8 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), + #[error("session error: {0}")] + Session(#[from] double_ratchets::SessionError), } #[derive(Error, Debug)] diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index 79d6a5a..b82bb22 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -1,4 +1,5 @@ mod api; +mod chat; mod context; mod conversation; mod crypto; @@ -6,6 +7,7 @@ mod errors; mod identity; mod inbox; mod proto; +mod storage; mod types; mod utils; diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs new file mode 100644 index 0000000..3bd0085 --- /dev/null +++ b/conversations/src/storage/db.rs @@ -0,0 +1,260 @@ +//! Chat-specific storage implementation. + +use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; +use x25519_dalek::StaticSecret; + +use super::types::{ChatRecord, IdentityRecord}; +use crate::identity::Identity; + +/// Schema for chat storage tables. +/// Note: Ratchet state is stored by double_ratchets::RatchetStorage separately. +const CHAT_SCHEMA: &str = " + -- Identity table (single row) + CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + secret_key BLOB NOT NULL + ); + + -- Inbox ephemeral keys for handshakes + CREATE TABLE IF NOT EXISTS inbox_keys ( + public_key_hex TEXT PRIMARY KEY, + secret_key BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + + -- Chat metadata + CREATE TABLE IF NOT EXISTS chats ( + chat_id TEXT PRIMARY KEY, + chat_type TEXT NOT NULL, + remote_public_key BLOB, + remote_address TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); +"; + +/// Chat-specific storage operations. +/// +/// This struct wraps a SqliteDb and provides domain-specific +/// storage operations for chat state (identity, inbox keys, chat metadata). +/// +/// Note: Ratchet state persistence is delegated to double_ratchets::RatchetStorage. +pub struct ChatStorage { + db: SqliteDb, +} + +impl ChatStorage { + /// Creates a new ChatStorage with the given configuration. + pub fn new(config: StorageConfig) -> Result { + let db = SqliteDb::new(config)?; + Self::run_migration(db) + } + + /// Creates a new chat storage with the given database. + fn run_migration(db: SqliteDb) -> Result { + db.connection().execute_batch(CHAT_SCHEMA)?; + Ok(Self { db }) + } + + // ==================== Identity Operations ==================== + + /// Saves the identity (secret key). + pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { + let record = IdentityRecord::from(identity); + self.db.connection().execute( + "INSERT OR REPLACE INTO identity (id, secret_key) VALUES (1, ?1)", + params![record.secret_key.as_slice()], + )?; + Ok(()) + } + + /// Loads the identity if it exists. + pub fn load_identity(&self) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT secret_key FROM identity WHERE id = 1")?; + + let result = stmt.query_row([], |row| { + let secret_key: Vec = row.get(0)?; + Ok(secret_key) + }); + + match result { + Ok(secret_key) => { + let bytes: [u8; 32] = secret_key + .try_into() + .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; + let record = IdentityRecord { secret_key: bytes }; + Ok(Some(Identity::from(record))) + } + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // ==================== Inbox Key Operations ==================== + + /// Saves an inbox ephemeral key. + pub fn save_inbox_key( + &mut self, + public_key_hex: &str, + secret: &StaticSecret, + ) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO inbox_keys (public_key_hex, secret_key) VALUES (?1, ?2)", + params![public_key_hex, secret.as_bytes().as_slice()], + )?; + Ok(()) + } + + /// Loads a single inbox ephemeral key by public key hex. + pub fn load_inbox_key( + &self, + public_key_hex: &str, + ) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT secret_key FROM inbox_keys WHERE public_key_hex = ?1")?; + + let result = stmt.query_row(params![public_key_hex], |row| { + let secret_key: Vec = row.get(0)?; + Ok(secret_key) + }); + + match result { + Ok(secret_key) => { + let bytes: [u8; 32] = secret_key + .try_into() + .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; + Ok(Some(StaticSecret::from(bytes))) + } + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Deletes an inbox ephemeral key after it has been used. + pub fn delete_inbox_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { + self.db.connection().execute( + "DELETE FROM inbox_keys WHERE public_key_hex = ?1", + params![public_key_hex], + )?; + Ok(()) + } + + // ==================== Chat Metadata Operations ==================== + + /// Saves a chat record. + pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO chats (chat_id, chat_type, remote_public_key, remote_address, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + chat.chat_id, + chat.chat_type, + chat.remote_public_key.as_ref().map(|k| k.as_slice()), + chat.remote_address, + chat.created_at, + ], + )?; + Ok(()) + } + + /// Lists all chat IDs. + pub fn list_chat_ids(&self) -> Result, StorageError> { + let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?; + let rows = stmt.query_map([], |row| row.get(0))?; + + let mut ids = Vec::new(); + for row in rows { + ids.push(row?); + } + + Ok(ids) + } + + /// Checks if a chat exists in storage. + pub fn chat_exists(&self, chat_id: &str) -> Result { + let mut stmt = self + .db + .connection() + .prepare("SELECT 1 FROM chats WHERE chat_id = ?1")?; + + let exists = stmt.exists(params![chat_id])?; + Ok(exists) + } + + /// Finds a chat by remote address. + /// Returns the chat_id if found, None otherwise. + #[allow(dead_code)] + pub fn find_chat_by_remote_address( + &self, + remote_address: &str, + ) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT chat_id FROM chats WHERE remote_address = ?1 LIMIT 1")?; + + let mut rows = stmt.query(params![remote_address])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get(0)?)) + } else { + Ok(None) + } + } + + /// Deletes a chat record. + /// Note: Ratchet state must be deleted separately via RatchetStorage. + pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), StorageError> { + self.db + .connection() + .execute("DELETE FROM chats WHERE chat_id = ?1", params![chat_id])?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + // Initially no identity + assert!(storage.load_identity().unwrap().is_none()); + + // Save identity + let identity = Identity::new(); + let address = identity.address(); + storage.save_identity(&identity).unwrap(); + + // Load identity + let loaded = storage.load_identity().unwrap().unwrap(); + assert_eq!(loaded.address(), address); + } + + #[test] + fn test_chat_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + let secret = x25519_dalek::StaticSecret::random(); + let remote_key = x25519_dalek::PublicKey::from(&secret); + let chat = ChatRecord::new_private( + "chat_123".to_string(), + remote_key, + "delivery_addr".to_string(), + ); + + // Save chat + storage.save_chat(&chat).unwrap(); + + // List chats + let ids = storage.list_chat_ids().unwrap(); + assert_eq!(ids, vec!["chat_123"]); + } +} diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs new file mode 100644 index 0000000..5153dbf --- /dev/null +++ b/conversations/src/storage/mod.rs @@ -0,0 +1,14 @@ +//! Storage module for persisting chat state. +//! +//! This module provides storage implementations for the chat manager state, +//! built on top of the shared `storage` crate. +//! +//! Note: This module is internal. Users should use `ChatManager` which +//! handles all storage operations automatically. + +mod db; +pub(crate) mod types; + +pub(crate) use db::ChatStorage; +pub(crate) use storage::StorageError; +pub(crate) use types::ChatRecord; diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs new file mode 100644 index 0000000..553ac1b --- /dev/null +++ b/conversations/src/storage/types.rs @@ -0,0 +1,62 @@ +//! Storage record types for serialization/deserialization. +//! +//! Note: Ratchet state types (RatchetStateRecord, SkippedKeyRecord) are in +//! double_ratchets::storage module and handled by RatchetStorage. + +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::identity::Identity; + +/// Record for storing identity (secret key). +#[derive(Debug)] +pub struct IdentityRecord { + /// The secret key bytes (32 bytes). + pub secret_key: [u8; 32], +} + +impl From<&Identity> for IdentityRecord { + fn from(identity: &Identity) -> Self { + Self { + secret_key: identity.secret().to_bytes(), + } + } +} + +impl From for Identity { + fn from(record: IdentityRecord) -> Self { + let secret = StaticSecret::from(record.secret_key); + Identity::from_secret(secret) + } +} + +/// Record for storing chat metadata. +/// Note: The actual double ratchet state is stored separately by RatchetStorage. +#[derive(Debug, Clone)] +pub struct ChatRecord { + /// Unique chat identifier. + pub chat_id: String, + /// Type of chat (e.g., "private_v1", "group_v1"). + pub chat_type: String, + /// Remote party's public key (for private chats). + pub remote_public_key: Option<[u8; 32]>, + /// Remote party's delivery address. + pub remote_address: String, + /// Creation timestamp (unix millis). + pub created_at: i64, +} + +impl ChatRecord { + pub fn new_private( + chat_id: String, + remote_public_key: PublicKey, + remote_address: String, + ) -> Self { + Self { + chat_id, + chat_type: "private_v1".to_string(), + remote_public_key: Some(remote_public_key.to_bytes()), + remote_address, + created_at: crate::utils::timestamp_millis() as i64, + } + } +} diff --git a/double-ratchets/src/storage/db.rs b/double-ratchets/src/storage/db.rs index 43b3f4b..a41d2bd 100644 --- a/double-ratchets/src/storage/db.rs +++ b/double-ratchets/src/storage/db.rs @@ -47,6 +47,12 @@ pub struct RatchetStorage { } impl RatchetStorage { + /// Creates a new RatchetStorage with the given configuration. + pub fn with_config(config: storage::StorageConfig) -> Result { + let db = SqliteDb::new(config)?; + Self::run_migration(db) + } + /// Opens an existing encrypted database file. pub fn new(path: &str, key: &str) -> Result { let db = SqliteDb::sqlcipher(path.to_string(), key.to_string())?; diff --git a/double-ratchets/src/storage/session.rs b/double-ratchets/src/storage/session.rs index ea3cdfc..2598d85 100644 --- a/double-ratchets/src/storage/session.rs +++ b/double-ratchets/src/storage/session.rs @@ -13,16 +13,19 @@ use super::RatchetStorage; /// A session wrapper that automatically persists ratchet state after operations. /// Provides rollback semantics - state is only saved if the operation succeeds. -pub struct RatchetSession<'a, D: HkdfInfo + Clone = DefaultDomain> { - storage: &'a mut RatchetStorage, +/// +/// This struct owns its storage, making it easy to store in other structs +/// and use across multiple operations without lifetime concerns. +pub struct RatchetSession { + storage: RatchetStorage, conversation_id: String, state: RatchetState, } -impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { +impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Opens an existing session from storage. pub fn open( - storage: &'a mut RatchetStorage, + storage: RatchetStorage, conversation_id: impl Into, ) -> Result { let conversation_id = conversation_id.into(); @@ -36,7 +39,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Creates a new session and persists the initial state. pub fn create( - storage: &'a mut RatchetStorage, + mut storage: RatchetStorage, conversation_id: impl Into, state: RatchetState, ) -> Result { @@ -51,7 +54,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Initializes a new session as a sender and persists the initial state. pub fn create_sender_session( - storage: &'a mut RatchetStorage, + storage: RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, remote_pub: PublicKey, @@ -65,7 +68,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Initializes a new session as a receiver and persists the initial state. pub fn create_receiver_session( - storage: &'a mut RatchetStorage, + storage: RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, dh_self: InstallationKeyPair, @@ -137,6 +140,12 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { &self.conversation_id } + /// Consumes the session and returns the underlying storage. + /// Useful when you need to reuse the storage for another session. + pub fn into_storage(self) -> RatchetStorage { + self.storage + } + /// Manually saves the current state. pub fn save(&mut self) -> Result<(), SessionError> { self.storage @@ -164,30 +173,29 @@ mod tests { #[test] fn test_session_create_and_open() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let alice: RatchetState = RatchetState::init_sender(shared_secret, *bob_keypair.public()); - // Create session - { - let session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); - assert_eq!(session.conversation_id(), "conv1"); - } + // Create session - session takes ownership of storage + let session = RatchetSession::create(storage, "conv1", alice).unwrap(); + assert_eq!(session.conversation_id(), "conv1"); + + // Get storage back from session to reopen + let storage = session.into_storage(); // Open existing session - { - let session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 0); - } + let session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 0); } #[test] fn test_session_encrypt_persists() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); @@ -195,158 +203,120 @@ mod tests { RatchetState::init_sender(shared_secret, *bob_keypair.public()); // Create and encrypt - { - let mut session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); - session.encrypt_message(b"Hello").unwrap(); - assert_eq!(session.state().msg_send, 1); - } + let mut session = RatchetSession::create(storage, "conv1", alice).unwrap(); + session.encrypt_message(b"Hello").unwrap(); + assert_eq!(session.state().msg_send, 1); + + // Get storage back and reopen + let storage = session.into_storage(); // Reopen - state should be persisted - { - let session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); - } + let session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); } #[test] fn test_session_full_conversation() { - let mut storage = create_test_storage(); + // Use separate in-memory storages for alice and bob (simulates different devices) + let alice_storage = create_test_storage(); + let bob_storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); - let alice: RatchetState = - RatchetState::init_sender(shared_secret, *bob_keypair.public()); - let bob: RatchetState = + let alice_state: RatchetState = + RatchetState::init_sender(shared_secret, bob_keypair.public().clone()); + let bob_state: RatchetState = RatchetState::init_receiver(shared_secret, bob_keypair); // Alice sends - let (ct, header) = { - let mut session = RatchetSession::create(&mut storage, "alice", alice).unwrap(); - session.encrypt_message(b"Hello Bob").unwrap() - }; + let mut alice_session = RatchetSession::create(alice_storage, "conv", alice_state).unwrap(); + let (ct, header) = alice_session.encrypt_message(b"Hello Bob").unwrap(); // Bob receives - let plaintext = { - let mut session = RatchetSession::create(&mut storage, "bob", bob).unwrap(); - session.decrypt_message(&ct, header).unwrap() - }; + let mut bob_session = RatchetSession::create(bob_storage, "conv", bob_state).unwrap(); + let plaintext = bob_session.decrypt_message(&ct, header).unwrap(); assert_eq!(plaintext, b"Hello Bob"); // Bob replies - let (ct2, header2) = { - let mut session: RatchetSession = - RatchetSession::open(&mut storage, "bob").unwrap(); - session.encrypt_message(b"Hi Alice").unwrap() - }; + let (ct2, header2) = bob_session.encrypt_message(b"Hi Alice").unwrap(); // Alice receives - let plaintext2 = { - let mut session: RatchetSession = - RatchetSession::open(&mut storage, "alice").unwrap(); - session.decrypt_message(&ct2, header2).unwrap() - }; + let plaintext2 = alice_session.decrypt_message(&ct2, header2).unwrap(); assert_eq!(plaintext2, b"Hi Alice"); } #[test] fn test_session_open_or_create() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First call creates - { - let session: RatchetSession = RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ) - .unwrap(); - assert_eq!(session.state().msg_send, 0); - } + let session: RatchetSession = + RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) + .unwrap(); + assert_eq!(session.state().msg_send, 0); + let storage = session.into_storage(); - // Second call opens existing - { - let mut session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - session.encrypt_message(b"test").unwrap(); - } + // Second call opens existing and encrypts + let mut session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + session.encrypt_message(b"test").unwrap(); + let storage = session.into_storage(); // Verify persistence - { - let session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); - } + let session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); } #[test] fn test_create_sender_session_fails_when_conversation_exists() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First creation succeeds - { - let _session: RatchetSession = RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ) - .unwrap(); - } + let session: RatchetSession = + RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) + .unwrap(); + let storage = session.into_storage(); // Second creation should fail with ConversationAlreadyExists - { - let result: Result, _> = - RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ); + let result: Result, _> = + RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()); - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); - } + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); } #[test] fn test_create_receiver_session_fails_when_conversation_exists() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); // First creation succeeds - { - let _session: RatchetSession = RatchetSession::create_receiver_session( - &mut storage, - "conv1", - shared_secret, - bob_keypair, - ) - .unwrap(); - } + let session: RatchetSession = + RatchetSession::create_receiver_session(storage, "conv1", shared_secret, bob_keypair) + .unwrap(); + let storage = session.into_storage(); // Second creation should fail with ConversationAlreadyExists - { - let another_keypair = InstallationKeyPair::generate(); - let result: Result, _> = - RatchetSession::create_receiver_session( - &mut storage, - "conv1", - shared_secret, - another_keypair, - ); + let another_keypair = InstallationKeyPair::generate(); + let result: Result, _> = + RatchetSession::create_receiver_session( + storage, + "conv1", + shared_secret, + another_keypair, + ); - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); - } + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); } } diff --git a/storage/src/sqlite.rs b/storage/src/sqlite.rs index 4d42e9d..8449532 100644 --- a/storage/src/sqlite.rs +++ b/storage/src/sqlite.rs @@ -8,8 +8,11 @@ use crate::StorageError; /// Configuration for SQLite storage. #[derive(Debug, Clone)] pub enum StorageConfig { - /// In-memory database (for testing). + /// In-memory database (isolated, for simple testing). InMemory, + /// Shared in-memory database with a name (multiple connections share data). + /// Use this when you need multiple storage instances to share the same in-memory DB. + SharedInMemory(String), /// File-based SQLite database. File(String), /// SQLCipher encrypted database. @@ -29,6 +32,17 @@ impl SqliteDb { pub fn new(config: StorageConfig) -> Result { let conn = match config { StorageConfig::InMemory => Connection::open_in_memory()?, + StorageConfig::SharedInMemory(ref name) => { + // Use URI mode to create a shared in-memory database + // Multiple connections with the same name share the same data + let uri = format!("file:{}?mode=memory&cache=shared", name); + Connection::open_with_flags( + &uri, + rusqlite::OpenFlags::SQLITE_OPEN_URI + | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE + | rusqlite::OpenFlags::SQLITE_OPEN_CREATE, + )? + } StorageConfig::File(ref path) => Connection::open(path)?, StorageConfig::Encrypted { ref path, ref key } => { let conn = Connection::open(path)?; From e099d5fd15512ad5fbfde4150ea05e09824d5c2d Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:09:18 +0800 Subject: [PATCH 02/18] fix: db types conversion --- conversations/src/identity.rs | 7 +++++++ conversations/src/storage/db.rs | 29 ++++++++++++++++++----------- conversations/src/storage/types.rs | 12 ++++++++---- storage/src/errors.rs | 4 ++++ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/conversations/src/identity.rs b/conversations/src/identity.rs index 76c2700..8ca27be 100644 --- a/conversations/src/identity.rs +++ b/conversations/src/identity.rs @@ -24,6 +24,13 @@ impl Identity { } } + pub fn from_secret(name: impl Into, secret: PrivateKey) -> Self { + Self { + name: name.into(), + secret, + } + } + pub fn public_key(&self) -> PublicKey { PublicKey::from(&self.secret) } diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 3bd0085..84ced02 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -12,6 +12,7 @@ const CHAT_SCHEMA: &str = " -- Identity table (single row) CREATE TABLE IF NOT EXISTS identity ( id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL, secret_key BLOB NOT NULL ); @@ -61,10 +62,12 @@ impl ChatStorage { /// Saves the identity (secret key). pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { - let record = IdentityRecord::from(identity); self.db.connection().execute( - "INSERT OR REPLACE INTO identity (id, secret_key) VALUES (1, ?1)", - params![record.secret_key.as_slice()], + "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", + params![ + identity.get_name(), + identity.secret().DANGER_to_bytes().as_slice() + ], )?; Ok(()) } @@ -74,19 +77,23 @@ impl ChatStorage { let mut stmt = self .db .connection() - .prepare("SELECT secret_key FROM identity WHERE id = 1")?; + .prepare("SELECT name, secret_key FROM identity WHERE id = 1")?; let result = stmt.query_row([], |row| { - let secret_key: Vec = row.get(0)?; - Ok(secret_key) + let name: String = row.get(0)?; + let secret_key: Vec = row.get(1)?; + Ok((name, secret_key)) }); match result { - Ok(secret_key) => { + Ok((name, secret_key)) => { let bytes: [u8; 32] = secret_key .try_into() .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; - let record = IdentityRecord { secret_key: bytes }; + let record = IdentityRecord { + name, + secret_key: bytes, + }; Ok(Some(Identity::from(record))) } Err(RusqliteError::QueryReturnedNoRows) => Ok(None), @@ -229,13 +236,13 @@ mod tests { assert!(storage.load_identity().unwrap().is_none()); // Save identity - let identity = Identity::new(); - let address = identity.address(); + let identity = Identity::new("default"); + let pubkey = identity.public_key(); storage.save_identity(&identity).unwrap(); // Load identity let loaded = storage.load_identity().unwrap().unwrap(); - assert_eq!(loaded.address(), address); + assert_eq!(loaded.public_key(), pubkey); } #[test] diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index 553ac1b..bc0fa95 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -3,13 +3,16 @@ //! Note: Ratchet state types (RatchetStateRecord, SkippedKeyRecord) are in //! double_ratchets::storage module and handled by RatchetStorage. -use x25519_dalek::{PublicKey, StaticSecret}; +use x25519_dalek::PublicKey; +use crate::crypto::PrivateKey; use crate::identity::Identity; /// Record for storing identity (secret key). #[derive(Debug)] pub struct IdentityRecord { + /// The identity name. + pub name: String, /// The secret key bytes (32 bytes). pub secret_key: [u8; 32], } @@ -17,15 +20,16 @@ pub struct IdentityRecord { impl From<&Identity> for IdentityRecord { fn from(identity: &Identity) -> Self { Self { - secret_key: identity.secret().to_bytes(), + name: identity.get_name().to_string(), + secret_key: identity.secret().DANGER_to_bytes(), } } } impl From for Identity { fn from(record: IdentityRecord) -> Self { - let secret = StaticSecret::from(record.secret_key); - Identity::from_secret(secret) + let secret = PrivateKey::from(record.secret_key); + Identity::from_secret(record.name, secret) } } diff --git a/storage/src/errors.rs b/storage/src/errors.rs index 1410f85..9d65d64 100644 --- a/storage/src/errors.rs +++ b/storage/src/errors.rs @@ -26,6 +26,10 @@ pub enum StorageError { /// Transaction error. #[error("transaction error: {0}")] Transaction(String), + + /// Invalid data error. + #[error("invalid data: {0}")] + InvalidData(String), } impl From for StorageError { From f4c08bd04816cd1ffad0d3fda517cf51ad4111f5 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:23:13 +0800 Subject: [PATCH 03/18] feat: run migrations from sql files --- conversations/src/storage/db.rs | 38 +++------------- conversations/src/storage/migrations.rs | 44 +++++++++++++++++++ .../storage/migrations/001_initial_schema.sql | 27 ++++++++++++ conversations/src/storage/mod.rs | 1 + storage/src/lib.rs | 2 +- 5 files changed, 78 insertions(+), 34 deletions(-) create mode 100644 conversations/src/storage/migrations.rs create mode 100644 conversations/src/storage/migrations/001_initial_schema.sql diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 84ced02..807347b 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -3,38 +3,10 @@ use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; use x25519_dalek::StaticSecret; +use super::migrations; use super::types::{ChatRecord, IdentityRecord}; use crate::identity::Identity; -/// Schema for chat storage tables. -/// Note: Ratchet state is stored by double_ratchets::RatchetStorage separately. -const CHAT_SCHEMA: &str = " - -- Identity table (single row) - CREATE TABLE IF NOT EXISTS identity ( - id INTEGER PRIMARY KEY CHECK (id = 1), - name TEXT NOT NULL, - secret_key BLOB NOT NULL - ); - - -- Inbox ephemeral keys for handshakes - CREATE TABLE IF NOT EXISTS inbox_keys ( - public_key_hex TEXT PRIMARY KEY, - secret_key BLOB NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) - ); - - -- Chat metadata - CREATE TABLE IF NOT EXISTS chats ( - chat_id TEXT PRIMARY KEY, - chat_type TEXT NOT NULL, - remote_public_key BLOB, - remote_address TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); -"; - /// Chat-specific storage operations. /// /// This struct wraps a SqliteDb and provides domain-specific @@ -49,12 +21,12 @@ impl ChatStorage { /// Creates a new ChatStorage with the given configuration. pub fn new(config: StorageConfig) -> Result { let db = SqliteDb::new(config)?; - Self::run_migration(db) + Self::run_migrations(db) } - /// Creates a new chat storage with the given database. - fn run_migration(db: SqliteDb) -> Result { - db.connection().execute_batch(CHAT_SCHEMA)?; + /// Applies all migrations and returns the storage instance. + fn run_migrations(db: SqliteDb) -> Result { + migrations::apply_migrations(db.connection())?; Ok(Self { db }) } diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs new file mode 100644 index 0000000..41b3cb4 --- /dev/null +++ b/conversations/src/storage/migrations.rs @@ -0,0 +1,44 @@ +//! Database migrations module. +//! +//! SQL migrations are embedded at compile time and applied in order. + +use storage::{Connection, StorageError}; + +/// Embeds and returns all migration SQL files in order. +pub fn get_migrations() -> Vec<(&'static str, &'static str)> { + vec![( + "001_initial_schema", + include_str!("migrations/001_initial_schema.sql"), + )] +} + +/// Applies all migrations to the database. +/// Uses a simple version tracking table to avoid re-running migrations. +pub fn apply_migrations(conn: &Connection) -> Result<(), StorageError> { + // Create migrations tracking table if it doesn't exist + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + );", + )?; + + for (name, sql) in get_migrations() { + // Check if migration already applied + let already_applied: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM _migrations WHERE name = ?1)", + [name], + |row| row.get(0), + )?; + + if !already_applied { + // Apply migration + conn.execute_batch(sql)?; + + // Record migration + conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + } + } + + Ok(()) +} diff --git a/conversations/src/storage/migrations/001_initial_schema.sql b/conversations/src/storage/migrations/001_initial_schema.sql new file mode 100644 index 0000000..70b5359 --- /dev/null +++ b/conversations/src/storage/migrations/001_initial_schema.sql @@ -0,0 +1,27 @@ +-- Initial schema for chat storage +-- Migration: 001_initial_schema + +-- Identity table (single row) +CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL, + secret_key BLOB NOT NULL +); + +-- Inbox ephemeral keys for handshakes +CREATE TABLE IF NOT EXISTS inbox_keys ( + public_key_hex TEXT PRIMARY KEY, + secret_key BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- Chat metadata +CREATE TABLE IF NOT EXISTS chats ( + chat_id TEXT PRIMARY KEY, + chat_type TEXT NOT NULL, + remote_public_key BLOB, + remote_address TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index 5153dbf..5d33d87 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -7,6 +7,7 @@ //! handles all storage operations automatically. mod db; +mod migrations; pub(crate) mod types; pub(crate) use db::ChatStorage; diff --git a/storage/src/lib.rs b/storage/src/lib.rs index bacc9b6..9240dc2 100644 --- a/storage/src/lib.rs +++ b/storage/src/lib.rs @@ -12,4 +12,4 @@ pub use errors::StorageError; pub use sqlite::{SqliteDb, StorageConfig}; // Re-export rusqlite types that domain crates will need -pub use rusqlite::{Error as RusqliteError, Transaction, params}; +pub use rusqlite::{Connection, Error as RusqliteError, Transaction, params}; From 37eb2749b2be9ec95c0d1e6a0f92f236417682b0 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:32:47 +0800 Subject: [PATCH 04/18] feat: persist identity --- conversations/src/api.rs | 22 +++++++++++ conversations/src/context.rs | 76 +++++++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index bd1e300..8ba81b9 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -13,6 +13,8 @@ use safer_ffi::{ prelude::{c_slice, repr_c}, }; +use storage::StorageConfig; + use crate::{ context::{Context, Introduction}, errors::ChatError, @@ -54,6 +56,26 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { Box::new(ContextHandle(Context::new_with_name(&*name))).into() } +/// Creates a new libchat Context with file-based persistent storage. +/// +/// The identity will be loaded from storage if it exists, or created and saved if not. +/// +/// # Parameters +/// - name: Friendly name for the identity (used if creating new identity) +/// - db_path: Path to the SQLite database file +/// +/// # Returns +/// Opaque handle to the context. Must be freed with destroy_context() +#[ffi_export] +pub fn create_context_with_storage( + name: repr_c::String, + db_path: repr_c::String, +) -> repr_c::Box { + let config = StorageConfig::File(db_path.to_string()); + let ctx = Context::open(&*name, config).expect("failed to open context with storage"); + Box::new(ContextHandle(ctx)).into() +} + /// Returns the friendly name of the contexts installation. /// #[ffi_export] diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 2b36f68..87a68fe 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,34 +1,73 @@ use std::rc::Rc; +use storage::StorageConfig; + use crate::{ conversation::{ConversationId, ConversationStore, Convo, Id}, errors::ChatError, identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, + storage::{ChatStorage, StorageError}, types::{AddressedEnvelope, ContentData}, }; pub use crate::conversation::ConversationIdOwned; pub use crate::inbox::Introduction; +/// Error type for Context operations. +#[derive(Debug, thiserror::Error)] +pub enum ContextError { + #[error("chat error: {0}")] + Chat(#[from] ChatError), + + #[error("storage error: {0}")] + Storage(#[from] StorageError), +} + // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, + storage: ChatStorage, } impl Context { - pub fn new_with_name(name: impl Into) -> Self { - let identity = Rc::new(Identity::new(name)); - let inbox = Inbox::new(Rc::clone(&identity)); // - Self { + /// Opens or creates a Context with the given storage configuration. + /// + /// If an identity exists in storage, it will be restored. + /// Otherwise, a new identity will be created with the given name and saved. + pub fn open(name: impl Into, config: StorageConfig) -> Result { + let mut storage = ChatStorage::new(config)?; + let name = name.into(); + + // Load or create identity + let identity = if let Some(identity) = storage.load_identity()? { + identity + } else { + let identity = Identity::new(&name); + storage.save_identity(&identity)?; + identity + }; + + let identity = Rc::new(identity); + let inbox = Inbox::new(Rc::clone(&identity)); + + Ok(Self { _identity: identity, store: ConversationStore::new(), inbox, - } + storage, + }) + } + + /// Creates a new in-memory Context (for testing). + /// + /// Uses in-memory SQLite database. Each call creates a new isolated database. + pub fn new_with_name(name: impl Into) -> Self { + Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail") } pub fn installation_name(&self) -> &str { @@ -195,4 +234,31 @@ mod tests { send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content); } } + + #[test] + fn identity_persistence() { + // Use file-based storage to test real persistence + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_identity.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Create context - this should create and save a new identity + let ctx1 = Context::open("alice", config.clone()).unwrap(); + let pubkey1 = ctx1._identity.public_key(); + let name1 = ctx1.installation_name().to_string(); + + // Drop and reopen - should load the same identity + drop(ctx1); + let ctx2 = Context::open("alice", config).unwrap(); + let pubkey2 = ctx2._identity.public_key(); + let name2 = ctx2.installation_name().to_string(); + + // Identity should be the same + assert_eq!(pubkey1, pubkey2, "public key should persist"); + assert_eq!(name1, name2, "name should persist"); + } } From 3a9ddadc887b777d5bc49d5d4a53518123627f90 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:48:53 +0800 Subject: [PATCH 05/18] fix: revert double ratchet storage refactor --- conversations/src/chat.rs | 615 -------------------- conversations/src/conversation/privatev1.rs | 48 +- conversations/src/lib.rs | 1 - conversations/src/storage/db.rs | 145 +---- conversations/src/storage/mod.rs | 7 - conversations/src/storage/types.rs | 37 -- double-ratchets/src/storage/db.rs | 6 - double-ratchets/src/storage/session.rs | 198 ++++--- 8 files changed, 117 insertions(+), 940 deletions(-) delete mode 100644 conversations/src/chat.rs diff --git a/conversations/src/chat.rs b/conversations/src/chat.rs deleted file mode 100644 index 19b9be1..0000000 --- a/conversations/src/chat.rs +++ /dev/null @@ -1,615 +0,0 @@ -//! ChatManager with integrated SQLite persistence. -//! -//! This is the main entry point for the conversations API. It handles all -//! storage operations internally - users don't need to interact with storage directly. - -use std::rc::Rc; - -use double_ratchets::storage::RatchetStorage; -use prost::Message; - -use crate::{ - conversation::PrivateV1Convo, - conversation::{Convo, Id}, - errors::ChatError, - identity::Identity, - inbox::{Inbox, Introduction}, - proto, - storage::{ChatRecord, ChatStorage, StorageError}, - types::{AddressedEnvelope, ContentData}, -}; - -// Re-export StorageConfig from storage crate for convenience -pub use storage::StorageConfig; - -/// Error type for ChatManager operations. -#[derive(Debug, thiserror::Error)] -pub enum ChatManagerError { - #[error("chat error: {0}")] - Chat(#[from] ChatError), - - #[error("storage error: {0}")] - Storage(#[from] StorageError), - - #[error("chat not found: {0}")] - ChatNotFound(String), -} - -/// ChatManager is the main entry point for the chat API. -/// -/// It manages identity, inbox, and chats with all state persisted to SQLite. -/// Chats are loaded from storage on each operation - no in-memory caching. -/// Uses a single shared database for both chat metadata and ratchet state. -/// -/// # Example -/// -/// ```ignore -/// // Create a new chat manager with encrypted storage -/// let mut chat = ChatManager::open(StorageConfig::Encrypted { -/// path: "chat.db".into(), -/// key: "my_secret_key".into(), -/// })?; -/// -/// // Get your address to share with others -/// println!("My address: {}", chat.local_address()); -/// -/// // Create an intro bundle to share -/// let intro = chat.create_intro_bundle()?; -/// -/// // Start a chat with someone -/// let (chat_id, envelopes) = chat.start_private_chat(&their_intro, "Hello!")?; -/// // Send envelopes over the network... -/// -/// // Send more messages -/// let envelopes = chat.send_message(&chat_id, b"How are you?")?; -/// ``` -pub struct ChatManager { - identity: Rc, - inbox: Inbox, - /// Storage for chat metadata (identity, inbox keys, chat records). - storage: ChatStorage, - /// Storage config for creating ratchet storage instances. - /// For file/encrypted databases, SQLite handles connection efficiently. - /// For in-memory testing, use SharedInMemory to share data. - storage_config: StorageConfig, -} - -impl ChatManager { - /// Opens or creates a ChatManager with the given storage configuration. - /// - /// If an identity exists in storage, it will be restored. - /// Otherwise, a new identity will be created and saved. - pub fn open(config: StorageConfig) -> Result { - let mut storage = ChatStorage::new(config.clone())?; - - // Load or create identity - let identity = if let Some(identity) = storage.load_identity()? { - identity - } else { - let identity = Identity::new("default"); - storage.save_identity(&identity)?; - identity - }; - - let identity = Rc::new(identity); - let inbox = Inbox::new(Rc::clone(&identity)); - - Ok(Self { - identity, - inbox, - storage, - storage_config: config, - }) - } - - /// Creates a new in-memory ChatManager (for testing). - /// - /// Uses a shared in-memory SQLite database so that multiple storage - /// instances within the same ChatManager share data. - /// - /// The `db_name` should be unique per ChatManager instance to avoid - /// sharing data between different users. - pub fn in_memory(db_name: &str) -> Result { - Self::open(StorageConfig::SharedInMemory(db_name.to_string())) - } - - /// Creates a new RatchetStorage instance using the stored config. - fn create_ratchet_storage(&self) -> Result { - Ok(RatchetStorage::with_config(self.storage_config.clone())?) - } - - /// Load a chat from storage. - fn load_chat(&self, chat_id: &str) -> Result { - let ratchet_storage = self.create_ratchet_storage()?; - if ratchet_storage.exists(chat_id)? { - let base_conv_id = chat_id.parse()?; - Ok(PrivateV1Convo::open(ratchet_storage, base_conv_id)?) - } else if self.storage.chat_exists(chat_id)? { - // Chat metadata exists but no ratchet state - data inconsistency - Err(ChatManagerError::ChatNotFound(format!( - "{} (corrupted: missing ratchet state)", - chat_id - ))) - } else { - Err(ChatManagerError::ChatNotFound(chat_id.to_string())) - } - } - - /// Get the local identity's public address. - /// - /// This address can be shared with others so they can identify you. - pub fn local_address(&self) -> String { - hex::encode(self.identity.public_key().as_bytes()) - } - - /// Create an introduction bundle that can be shared with others. - /// - /// Others can use this bundle to initiate a chat with you. - /// Share it via QR code, link, or any other out-of-band method. - /// - /// The ephemeral key is automatically persisted to storage. - pub fn create_intro_bundle(&mut self) -> Result { - let (pkb, secret) = self.inbox.create_bundle(); - let intro = Introduction::from(pkb); - - // Persist the ephemeral key - let public_key_hex = hex::encode(intro.ephemeral_key.as_bytes()); - self.storage.save_inbox_key(&public_key_hex, &secret)?; - - Ok(intro) - } - - /// Start a new private conversation with someone using their introduction bundle. - /// - /// Returns the chat ID and envelopes that must be delivered to the remote party. - /// The chat state is automatically persisted (via RatchetSession). - pub fn start_private_chat( - &mut self, - remote_bundle: &Introduction, - initial_message: &str, - ) -> Result<(String, Vec), ChatManagerError> { - // Create new storage for this conversation's RatchetSession - let ratchet_storage = self.create_ratchet_storage()?; - - let (convo, payloads) = self.inbox.invite_to_private_convo( - ratchet_storage, - remote_bundle, - initial_message.to_string(), - )?; - - let chat_id = convo.id().to_string(); - - let envelopes: Vec = payloads - .into_iter() - .map(|p| p.to_envelope(chat_id.clone())) - .collect(); - - // Persist chat metadata - let chat_record = ChatRecord::new_private( - chat_id.clone(), - remote_bundle.installation_key, - payloads_delivery_address(&envelopes), - ); - self.storage.save_chat(&chat_record)?; - - // Ratchet state is automatically persisted by RatchetSession - // convo is dropped here - state already saved - - Ok((chat_id, envelopes)) - } - - /// Send a message to an existing chat. - /// - /// Returns envelopes that must be delivered to chat participants. - pub fn send_message( - &mut self, - chat_id: &str, - content: &[u8], - ) -> Result, ChatManagerError> { - // Load chat from storage - let mut chat = self.load_chat(chat_id)?; - - let payloads = chat.send_message(content)?; - - // Ratchet state is automatically persisted by RatchetSession - - let remote_id = chat.remote_id(); - Ok(payloads - .into_iter() - .map(|p| p.to_envelope(remote_id.clone())) - .collect()) - } - - /// Handle an incoming payload from the network. - /// - /// This processes both inbox handshakes (to establish new chats) and - /// messages for existing chats. - /// - /// Returns the decrypted content if successful. - /// Any new chats or state changes are automatically persisted. - pub fn handle_incoming(&mut self, payload: &[u8]) -> Result { - // Try to decode as an envelope - if let Ok(envelope) = proto::EnvelopeV1::decode(payload) { - let chat_id = &envelope.conversation_hint; - - // Check if we have this chat - if so, route to it for decryption - if !chat_id.is_empty() && self.chat_exists(chat_id)? { - return self.receive_message(chat_id, &envelope.payload); - } - - // We don't have this chat - try to handle as inbox handshake - // Pass the conversation_hint so both parties use the same chat ID - return self.handle_inbox_handshake(chat_id, &envelope.payload); - } - - // Not a valid envelope format - Err(ChatManagerError::Chat(ChatError::Protocol( - "invalid envelope format".to_string(), - ))) - } - - /// Handle an inbox handshake to establish a new chat. - fn handle_inbox_handshake( - &mut self, - conversation_hint: &str, - payload: &[u8], - ) -> Result { - // Extract the ephemeral key hex from the payload - let key_hex = Inbox::extract_ephemeral_key_hex(payload)?; - - // Load the ephemeral key from storage - let ephemeral_key = self - .storage - .load_inbox_key(&key_hex)? - .ok_or_else(|| ChatManagerError::Chat(ChatError::UnknownEphemeralKey()))?; - - let ratchet_storage = self.create_ratchet_storage()?; - let result = - self.inbox - .handle_frame(ratchet_storage, conversation_hint, payload, &ephemeral_key)?; - - let chat_id = result.convo.id().to_string(); - - // Persist the new chat metadata - let chat_record = ChatRecord { - chat_id: chat_id.clone(), - chat_type: "private_v1".to_string(), - remote_public_key: Some(result.remote_public_key), - remote_address: hex::encode(result.remote_public_key), - created_at: crate::utils::timestamp_millis() as i64, - }; - self.storage.save_chat(&chat_record)?; - - // Delete the ephemeral key from storage after successful handshake - self.storage.delete_inbox_key(&key_hex)?; - - // Ratchet state is automatically persisted by RatchetSession - // result.convo is dropped here - state already saved - - Ok(ContentData { - conversation_id: chat_id, - data: result.initial_content.unwrap_or_default(), - }) - } - - /// Receive and decrypt a message for an existing chat. - /// - /// The payload should be the raw encrypted payload bytes. - pub fn receive_message( - &mut self, - chat_id: &str, - payload: &[u8], - ) -> Result { - // Load chat from storage - let mut chat = self.load_chat(chat_id)?; - - // Decode and decrypt the payload - let encrypted_payload = proto::EncryptedPayload::decode(payload).map_err(|e| { - ChatManagerError::Chat(ChatError::Protocol(format!("failed to decode: {}", e))) - })?; - - let frame = chat.decrypt(encrypted_payload)?; - let content = PrivateV1Convo::extract_content(&frame).unwrap_or_default(); - - // Ratchet state is automatically persisted by RatchetSession - - Ok(ContentData { - conversation_id: chat_id.to_string(), - data: content, - }) - } - - /// List all chat IDs from storage. - pub fn list_chats(&self) -> Result, ChatManagerError> { - Ok(self.storage.list_chat_ids()?) - } - - /// Check if a chat exists in storage. - pub fn chat_exists(&self, chat_id: &str) -> Result { - Ok(self.storage.chat_exists(chat_id)?) - } - - /// Delete a chat from storage. - pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), ChatManagerError> { - self.storage.delete_chat(chat_id)?; - // Also delete ratchet state from double-ratchets storage - if let Ok(mut ratchet_storage) = self.create_ratchet_storage() { - let _ = ratchet_storage.delete(chat_id); - } - Ok(()) - } -} - -/// Extract delivery address from envelopes (helper function). -fn payloads_delivery_address(envelopes: &[AddressedEnvelope]) -> String { - envelopes - .first() - .map(|e| e.delivery_address.clone()) - .unwrap_or_else(|| "unknown".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_chat_manager() { - let manager = ChatManager::in_memory("test1").unwrap(); - assert!(!manager.local_address().is_empty()); - } - - #[test] - fn test_identity_persistence() { - let manager = ChatManager::in_memory("test2").unwrap(); - let address = manager.local_address(); - - // Identity should be persisted - let loaded = manager.storage.load_identity().unwrap(); - assert!(loaded.is_some()); - assert_eq!(loaded.unwrap().address(), address); - } - - #[test] - fn test_create_intro_bundle() { - let mut manager = ChatManager::in_memory("test3").unwrap(); - let bundle = manager.create_intro_bundle(); - assert!(bundle.is_ok()); - } - - #[test] - fn test_start_private_chat() { - let mut alice = ChatManager::in_memory("alice1").unwrap(); - let mut bob = ChatManager::in_memory("bob1").unwrap(); - - // Bob creates an intro bundle - let bob_intro = bob.create_intro_bundle().unwrap(); - - // Alice starts a chat with Bob - let result = alice.start_private_chat(&bob_intro, "Hello Bob!"); - assert!(result.is_ok()); - - let (chat_id, envelopes) = result.unwrap(); - assert!(!chat_id.is_empty()); - assert!(!envelopes.is_empty()); - - // Chat should be persisted - let stored = alice.list_chats().unwrap(); - assert!(stored.contains(&chat_id)); - } - - #[test] - fn test_inbox_key_persistence() { - let mut manager = ChatManager::in_memory("test4").unwrap(); - - // Create intro bundle (should persist ephemeral key) - let intro = manager.create_intro_bundle().unwrap(); - let key_hex = hex::encode(intro.ephemeral_key.as_bytes()); - - // Key should be persisted - load it directly - let loaded_key = manager.storage.load_inbox_key(&key_hex).unwrap(); - assert!(loaded_key.is_some()); - } - - #[test] - fn test_chat_exists() { - let mut alice = ChatManager::in_memory("alice2").unwrap(); - let mut bob = ChatManager::in_memory("bob2").unwrap(); - - let bob_intro = bob.create_intro_bundle().unwrap(); - let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); - - // Chat should exist - assert!(alice.chat_exists(&chat_id).unwrap()); - assert!(!alice.chat_exists("nonexistent").unwrap()); - } - - #[test] - fn test_delete_chat() { - let mut alice = ChatManager::in_memory("alice3").unwrap(); - let mut bob = ChatManager::in_memory("bob3").unwrap(); - - let bob_intro = bob.create_intro_bundle().unwrap(); - let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); - - // Delete chat - alice.delete_chat(&chat_id).unwrap(); - - // Chat should no longer exist - assert!(!alice.chat_exists(&chat_id).unwrap()); - assert!(alice.list_chats().unwrap().is_empty()); - } - - #[test] - fn test_ratchet_state_persistence() { - use tempfile::tempdir; - - // Create a temporary directory for the database - let dir = tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - - let mut bob = ChatManager::in_memory("bob4").unwrap(); - let bob_intro = bob.create_intro_bundle().unwrap(); - - let chat_id; - - // Scope 1: Create chat and send messages - { - let mut alice = - ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) - .unwrap(); - - let result = alice.start_private_chat(&bob_intro, "Message 1").unwrap(); - chat_id = result.0; - - // Send more messages - this advances the ratchet - alice.send_message(&chat_id, b"Message 2").unwrap(); - alice.send_message(&chat_id, b"Message 3").unwrap(); - - // Chat should be in storage - assert!(alice.chat_exists(&chat_id).unwrap()); - } - // alice is dropped here, simulating app close - - // Scope 2: Reopen and verify chat is restored - { - let mut alice2 = - ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) - .unwrap(); - - // Chat should still be in storage - assert!(alice2.list_chats().unwrap().contains(&chat_id)); - - // Send another message - this will load the chat and advance ratchet - let result = alice2.send_message(&chat_id, b"Message 4"); - assert!(result.is_ok(), "Should be able to send after restore"); - } - } - - #[test] - fn test_full_message_roundtrip() { - use tempfile::tempdir; - - // Use temp files instead of in-memory for proper storage sharing - let dir = tempdir().unwrap(); - let alice_db = dir.path().join("alice.db"); - let bob_db = dir.path().join("bob.db"); - - let mut alice = - ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())).unwrap(); - let mut bob = - ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())).unwrap(); - - // Bob creates an intro bundle and shares it with Alice - let bob_intro = bob.create_intro_bundle().unwrap(); - - // Alice starts a chat with Bob and sends "Hello!" - let (alice_chat_id, envelopes) = - alice.start_private_chat(&bob_intro, "Hello Bob!").unwrap(); - - // Verify Alice has the chat - assert!(alice.chat_exists(&alice_chat_id).unwrap()); - assert_eq!(alice.list_chats().unwrap().len(), 1); - - // Simulate network delivery: Bob receives the envelope - let envelope = envelopes.first().unwrap(); - let content = bob.handle_incoming(&envelope.data).unwrap(); - - // Bob should have received the message - assert_eq!(content.data, b"Hello Bob!"); - - // Bob should now have a chat - assert_eq!(bob.list_chats().unwrap().len(), 1); - let bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); - - // Bob replies to Alice - let bob_reply_envelopes = bob.send_message(&bob_chat_id, b"Hi Alice!").unwrap(); - assert!(!bob_reply_envelopes.is_empty()); - - // Alice receives Bob's reply - let bob_reply = bob_reply_envelopes.first().unwrap(); - let alice_received = alice.handle_incoming(&bob_reply.data).unwrap(); - - assert_eq!(alice_received.data, b"Hi Alice!"); - assert_eq!(alice_received.conversation_id, alice_chat_id); - - // Continue the conversation - Alice sends another message - let alice_envelopes = alice.send_message(&alice_chat_id, b"How are you?").unwrap(); - let alice_msg = alice_envelopes.first().unwrap(); - let bob_received = bob.handle_incoming(&alice_msg.data).unwrap(); - - assert_eq!(bob_received.data, b"How are you?"); - - // Bob replies again - let bob_envelopes = bob - .send_message(&bob_chat_id, b"I'm good, thanks!") - .unwrap(); - let bob_msg = bob_envelopes.first().unwrap(); - let alice_received2 = alice.handle_incoming(&bob_msg.data).unwrap(); - - assert_eq!(alice_received2.data, b"I'm good, thanks!"); - } - - #[test] - fn test_message_persistence_across_sessions() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let alice_db = dir.path().join("alice.db"); - let bob_db = dir.path().join("bob.db"); - - let alice_chat_id; - let bob_chat_id; - let bob_intro; - - // Phase 1: Establish chat - { - let mut alice = - ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) - .unwrap(); - let mut bob = - ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) - .unwrap(); - - bob_intro = bob.create_intro_bundle().unwrap(); - let (chat_id, envelopes) = alice.start_private_chat(&bob_intro, "Initial").unwrap(); - alice_chat_id = chat_id; - - // Bob receives - let envelope = envelopes.first().unwrap(); - let content = bob.handle_incoming(&envelope.data).unwrap(); - assert_eq!(content.data, b"Initial"); - bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); - } - // Both dropped - simulates app restart - - // Phase 2: Continue conversation after restart - { - let mut alice = - ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) - .unwrap(); - let mut bob = - ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) - .unwrap(); - - // Both should have persisted chats - assert!(alice.list_chats().unwrap().contains(&alice_chat_id)); - assert!(bob.list_chats().unwrap().contains(&bob_chat_id)); - - // Alice sends a message (chat loads from storage) - let envelopes = alice - .send_message(&alice_chat_id, b"After restart") - .unwrap(); - - // Bob receives (chat loads from storage) - let envelope = envelopes.first().unwrap(); - let content = bob.handle_incoming(&envelope.data).unwrap(); - assert_eq!(content.data, b"After restart"); - - // Bob replies - let bob_envelopes = bob.send_message(&bob_chat_id, b"Still works!").unwrap(); - let bob_msg = bob_envelopes.first().unwrap(); - let alice_received = alice.handle_incoming(&bob_msg.data).unwrap(); - assert_eq!(alice_received.data, b"Still works!"); - } - } -} diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index b924d3e..0b8042e 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -7,12 +7,9 @@ use chat_proto::logoschat::{ encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption}, }; use crypto::{PrivateKey, PublicKey, SymmetricKey32}; -use double_ratchets::{Header, InstallationKeyPair, RatchetSession, RatchetState, RatchetStorage}; +use double_ratchets::{Header, InstallationKeyPair, RatchetState}; use prost::{Message, bytes::Bytes}; -use std::{ - fmt::{self, Debug, Display, Formatter}, - str::FromStr, -}; +use std::fmt::Debug; use crate::{ conversation::{ChatError, ConversationId, Convo, Id}, @@ -55,34 +52,10 @@ impl BaseConvoId { } } -impl Display for BaseConvoId { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}", hex::encode(self.0)) - } -} - -impl FromStr for BaseConvoId { - type Err = ChatError; - - fn from_str(s: &str) -> Result { - let bytes = hex::decode(s).map_err(|_| ChatError::BadParsing("base conversation ID"))?; - - if bytes.len() != 18 { - return Err(ChatError::BadParsing("base conversation ID")); - } - - let mut arr = [0u8; 18]; - arr.copy_from_slice(&bytes); - - Ok(Self(arr)) - } -} - pub struct PrivateV1Convo { local_convo_id: String, remote_convo_id: String, dr_state: RatchetState, - session: Option, } impl PrivateV1Convo { @@ -101,7 +74,6 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, - session: None, } } @@ -121,25 +93,9 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, - session: None, } } - /// Open an existing conversation from storage. - pub fn open(storage: RatchetStorage, base_convo_id: BaseConvoId) -> Result { - let local_convo_id = base_convo_id.id_for_participant(Role::Responder); - let remote_convo_id = base_convo_id.id_for_participant(Role::Initiator); - - let session = RatchetSession::open(storage, &local_convo_id)?; - - Ok(Self { - local_convo_id, - remote_convo_id, - dr_state: session.state().clone(), - session: Some(session), - }) - } - fn encrypt(&mut self, frame: PrivateV1Frame) -> EncryptedPayload { let encoded_bytes = frame.encode_to_vec(); let (cipher_text, header) = self.dr_state.encrypt_message(&encoded_bytes); diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index b82bb22..e13c3fa 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -1,5 +1,4 @@ mod api; -mod chat; mod context; mod conversation; mod crypto; diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 807347b..86f40fd 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -1,10 +1,9 @@ //! Chat-specific storage implementation. use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; -use x25519_dalek::StaticSecret; use super::migrations; -use super::types::{ChatRecord, IdentityRecord}; +use super::types::IdentityRecord; use crate::identity::Identity; /// Chat-specific storage operations. @@ -72,128 +71,6 @@ impl ChatStorage { Err(e) => Err(e.into()), } } - - // ==================== Inbox Key Operations ==================== - - /// Saves an inbox ephemeral key. - pub fn save_inbox_key( - &mut self, - public_key_hex: &str, - secret: &StaticSecret, - ) -> Result<(), StorageError> { - self.db.connection().execute( - "INSERT OR REPLACE INTO inbox_keys (public_key_hex, secret_key) VALUES (?1, ?2)", - params![public_key_hex, secret.as_bytes().as_slice()], - )?; - Ok(()) - } - - /// Loads a single inbox ephemeral key by public key hex. - pub fn load_inbox_key( - &self, - public_key_hex: &str, - ) -> Result, StorageError> { - let mut stmt = self - .db - .connection() - .prepare("SELECT secret_key FROM inbox_keys WHERE public_key_hex = ?1")?; - - let result = stmt.query_row(params![public_key_hex], |row| { - let secret_key: Vec = row.get(0)?; - Ok(secret_key) - }); - - match result { - Ok(secret_key) => { - let bytes: [u8; 32] = secret_key - .try_into() - .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; - Ok(Some(StaticSecret::from(bytes))) - } - Err(RusqliteError::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Deletes an inbox ephemeral key after it has been used. - pub fn delete_inbox_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { - self.db.connection().execute( - "DELETE FROM inbox_keys WHERE public_key_hex = ?1", - params![public_key_hex], - )?; - Ok(()) - } - - // ==================== Chat Metadata Operations ==================== - - /// Saves a chat record. - pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> { - self.db.connection().execute( - "INSERT OR REPLACE INTO chats (chat_id, chat_type, remote_public_key, remote_address, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - chat.chat_id, - chat.chat_type, - chat.remote_public_key.as_ref().map(|k| k.as_slice()), - chat.remote_address, - chat.created_at, - ], - )?; - Ok(()) - } - - /// Lists all chat IDs. - pub fn list_chat_ids(&self) -> Result, StorageError> { - let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?; - let rows = stmt.query_map([], |row| row.get(0))?; - - let mut ids = Vec::new(); - for row in rows { - ids.push(row?); - } - - Ok(ids) - } - - /// Checks if a chat exists in storage. - pub fn chat_exists(&self, chat_id: &str) -> Result { - let mut stmt = self - .db - .connection() - .prepare("SELECT 1 FROM chats WHERE chat_id = ?1")?; - - let exists = stmt.exists(params![chat_id])?; - Ok(exists) - } - - /// Finds a chat by remote address. - /// Returns the chat_id if found, None otherwise. - #[allow(dead_code)] - pub fn find_chat_by_remote_address( - &self, - remote_address: &str, - ) -> Result, StorageError> { - let mut stmt = self - .db - .connection() - .prepare("SELECT chat_id FROM chats WHERE remote_address = ?1 LIMIT 1")?; - - let mut rows = stmt.query(params![remote_address])?; - if let Some(row) = rows.next()? { - Ok(Some(row.get(0)?)) - } else { - Ok(None) - } - } - - /// Deletes a chat record. - /// Note: Ratchet state must be deleted separately via RatchetStorage. - pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), StorageError> { - self.db - .connection() - .execute("DELETE FROM chats WHERE chat_id = ?1", params![chat_id])?; - Ok(()) - } } #[cfg(test)] @@ -216,24 +93,4 @@ mod tests { let loaded = storage.load_identity().unwrap().unwrap(); assert_eq!(loaded.public_key(), pubkey); } - - #[test] - fn test_chat_roundtrip() { - let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); - - let secret = x25519_dalek::StaticSecret::random(); - let remote_key = x25519_dalek::PublicKey::from(&secret); - let chat = ChatRecord::new_private( - "chat_123".to_string(), - remote_key, - "delivery_addr".to_string(), - ); - - // Save chat - storage.save_chat(&chat).unwrap(); - - // List chats - let ids = storage.list_chat_ids().unwrap(); - assert_eq!(ids, vec!["chat_123"]); - } } diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index 5d33d87..7a32751 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -1,10 +1,4 @@ //! Storage module for persisting chat state. -//! -//! This module provides storage implementations for the chat manager state, -//! built on top of the shared `storage` crate. -//! -//! Note: This module is internal. Users should use `ChatManager` which -//! handles all storage operations automatically. mod db; mod migrations; @@ -12,4 +6,3 @@ pub(crate) mod types; pub(crate) use db::ChatStorage; pub(crate) use storage::StorageError; -pub(crate) use types::ChatRecord; diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index bc0fa95..d82a421 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -1,9 +1,4 @@ //! Storage record types for serialization/deserialization. -//! -//! Note: Ratchet state types (RatchetStateRecord, SkippedKeyRecord) are in -//! double_ratchets::storage module and handled by RatchetStorage. - -use x25519_dalek::PublicKey; use crate::crypto::PrivateKey; use crate::identity::Identity; @@ -32,35 +27,3 @@ impl From for Identity { Identity::from_secret(record.name, secret) } } - -/// Record for storing chat metadata. -/// Note: The actual double ratchet state is stored separately by RatchetStorage. -#[derive(Debug, Clone)] -pub struct ChatRecord { - /// Unique chat identifier. - pub chat_id: String, - /// Type of chat (e.g., "private_v1", "group_v1"). - pub chat_type: String, - /// Remote party's public key (for private chats). - pub remote_public_key: Option<[u8; 32]>, - /// Remote party's delivery address. - pub remote_address: String, - /// Creation timestamp (unix millis). - pub created_at: i64, -} - -impl ChatRecord { - pub fn new_private( - chat_id: String, - remote_public_key: PublicKey, - remote_address: String, - ) -> Self { - Self { - chat_id, - chat_type: "private_v1".to_string(), - remote_public_key: Some(remote_public_key.to_bytes()), - remote_address, - created_at: crate::utils::timestamp_millis() as i64, - } - } -} diff --git a/double-ratchets/src/storage/db.rs b/double-ratchets/src/storage/db.rs index a41d2bd..43b3f4b 100644 --- a/double-ratchets/src/storage/db.rs +++ b/double-ratchets/src/storage/db.rs @@ -47,12 +47,6 @@ pub struct RatchetStorage { } impl RatchetStorage { - /// Creates a new RatchetStorage with the given configuration. - pub fn with_config(config: storage::StorageConfig) -> Result { - let db = SqliteDb::new(config)?; - Self::run_migration(db) - } - /// Opens an existing encrypted database file. pub fn new(path: &str, key: &str) -> Result { let db = SqliteDb::sqlcipher(path.to_string(), key.to_string())?; diff --git a/double-ratchets/src/storage/session.rs b/double-ratchets/src/storage/session.rs index 2598d85..ea3cdfc 100644 --- a/double-ratchets/src/storage/session.rs +++ b/double-ratchets/src/storage/session.rs @@ -13,19 +13,16 @@ use super::RatchetStorage; /// A session wrapper that automatically persists ratchet state after operations. /// Provides rollback semantics - state is only saved if the operation succeeds. -/// -/// This struct owns its storage, making it easy to store in other structs -/// and use across multiple operations without lifetime concerns. -pub struct RatchetSession { - storage: RatchetStorage, +pub struct RatchetSession<'a, D: HkdfInfo + Clone = DefaultDomain> { + storage: &'a mut RatchetStorage, conversation_id: String, state: RatchetState, } -impl<'a, D: HkdfInfo + Clone> RatchetSession { +impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Opens an existing session from storage. pub fn open( - storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: impl Into, ) -> Result { let conversation_id = conversation_id.into(); @@ -39,7 +36,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Creates a new session and persists the initial state. pub fn create( - mut storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: impl Into, state: RatchetState, ) -> Result { @@ -54,7 +51,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Initializes a new session as a sender and persists the initial state. pub fn create_sender_session( - storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, remote_pub: PublicKey, @@ -68,7 +65,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Initializes a new session as a receiver and persists the initial state. pub fn create_receiver_session( - storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, dh_self: InstallationKeyPair, @@ -140,12 +137,6 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { &self.conversation_id } - /// Consumes the session and returns the underlying storage. - /// Useful when you need to reuse the storage for another session. - pub fn into_storage(self) -> RatchetStorage { - self.storage - } - /// Manually saves the current state. pub fn save(&mut self) -> Result<(), SessionError> { self.storage @@ -173,29 +164,30 @@ mod tests { #[test] fn test_session_create_and_open() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let alice: RatchetState = RatchetState::init_sender(shared_secret, *bob_keypair.public()); - // Create session - session takes ownership of storage - let session = RatchetSession::create(storage, "conv1", alice).unwrap(); - assert_eq!(session.conversation_id(), "conv1"); - - // Get storage back from session to reopen - let storage = session.into_storage(); + // Create session + { + let session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); + assert_eq!(session.conversation_id(), "conv1"); + } // Open existing session - let session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 0); + { + let session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 0); + } } #[test] fn test_session_encrypt_persists() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); @@ -203,120 +195,158 @@ mod tests { RatchetState::init_sender(shared_secret, *bob_keypair.public()); // Create and encrypt - let mut session = RatchetSession::create(storage, "conv1", alice).unwrap(); - session.encrypt_message(b"Hello").unwrap(); - assert_eq!(session.state().msg_send, 1); - - // Get storage back and reopen - let storage = session.into_storage(); + { + let mut session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); + session.encrypt_message(b"Hello").unwrap(); + assert_eq!(session.state().msg_send, 1); + } // Reopen - state should be persisted - let session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); + { + let session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); + } } #[test] fn test_session_full_conversation() { - // Use separate in-memory storages for alice and bob (simulates different devices) - let alice_storage = create_test_storage(); - let bob_storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); - let alice_state: RatchetState = - RatchetState::init_sender(shared_secret, bob_keypair.public().clone()); - let bob_state: RatchetState = + let alice: RatchetState = + RatchetState::init_sender(shared_secret, *bob_keypair.public()); + let bob: RatchetState = RatchetState::init_receiver(shared_secret, bob_keypair); // Alice sends - let mut alice_session = RatchetSession::create(alice_storage, "conv", alice_state).unwrap(); - let (ct, header) = alice_session.encrypt_message(b"Hello Bob").unwrap(); + let (ct, header) = { + let mut session = RatchetSession::create(&mut storage, "alice", alice).unwrap(); + session.encrypt_message(b"Hello Bob").unwrap() + }; // Bob receives - let mut bob_session = RatchetSession::create(bob_storage, "conv", bob_state).unwrap(); - let plaintext = bob_session.decrypt_message(&ct, header).unwrap(); + let plaintext = { + let mut session = RatchetSession::create(&mut storage, "bob", bob).unwrap(); + session.decrypt_message(&ct, header).unwrap() + }; assert_eq!(plaintext, b"Hello Bob"); // Bob replies - let (ct2, header2) = bob_session.encrypt_message(b"Hi Alice").unwrap(); + let (ct2, header2) = { + let mut session: RatchetSession = + RatchetSession::open(&mut storage, "bob").unwrap(); + session.encrypt_message(b"Hi Alice").unwrap() + }; // Alice receives - let plaintext2 = alice_session.decrypt_message(&ct2, header2).unwrap(); + let plaintext2 = { + let mut session: RatchetSession = + RatchetSession::open(&mut storage, "alice").unwrap(); + session.decrypt_message(&ct2, header2).unwrap() + }; assert_eq!(plaintext2, b"Hi Alice"); } #[test] fn test_session_open_or_create() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First call creates - let session: RatchetSession = - RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) - .unwrap(); - assert_eq!(session.state().msg_send, 0); - let storage = session.into_storage(); + { + let session: RatchetSession = RatchetSession::create_sender_session( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ) + .unwrap(); + assert_eq!(session.state().msg_send, 0); + } - // Second call opens existing and encrypts - let mut session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - session.encrypt_message(b"test").unwrap(); - let storage = session.into_storage(); + // Second call opens existing + { + let mut session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + session.encrypt_message(b"test").unwrap(); + } // Verify persistence - let session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); + { + let session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); + } } #[test] fn test_create_sender_session_fails_when_conversation_exists() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First creation succeeds - let session: RatchetSession = - RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) - .unwrap(); - let storage = session.into_storage(); + { + let _session: RatchetSession = RatchetSession::create_sender_session( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ) + .unwrap(); + } // Second creation should fail with ConversationAlreadyExists - let result: Result, _> = - RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()); + { + let result: Result, _> = + RatchetSession::create_sender_session( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ); - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + } } #[test] fn test_create_receiver_session_fails_when_conversation_exists() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); // First creation succeeds - let session: RatchetSession = - RatchetSession::create_receiver_session(storage, "conv1", shared_secret, bob_keypair) - .unwrap(); - let storage = session.into_storage(); - - // Second creation should fail with ConversationAlreadyExists - let another_keypair = InstallationKeyPair::generate(); - let result: Result, _> = - RatchetSession::create_receiver_session( - storage, + { + let _session: RatchetSession = RatchetSession::create_receiver_session( + &mut storage, "conv1", shared_secret, - another_keypair, - ); + bob_keypair, + ) + .unwrap(); + } - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + // Second creation should fail with ConversationAlreadyExists + { + let another_keypair = InstallationKeyPair::generate(); + let result: Result, _> = + RatchetSession::create_receiver_session( + &mut storage, + "conv1", + shared_secret, + another_keypair, + ); + + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + } } } From 7019b04ccb2405f8999e061c79592f0b2123d9d9 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:57:25 +0800 Subject: [PATCH 06/18] fix: clean --- conversations/src/context.rs | 1 + conversations/src/errors.rs | 2 -- .../storage/migrations/001_initial_schema.sql | 18 ------------------ conversations/src/storage/types.rs | 9 --------- storage/src/sqlite.rs | 16 +--------------- 5 files changed, 2 insertions(+), 44 deletions(-) diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 87a68fe..fe410d0 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -31,6 +31,7 @@ pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, + #[allow(dead_code)] // Will be used for conversation persistence storage: ChatStorage, } diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index 1df0e68..d551960 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -20,8 +20,6 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), - #[error("session error: {0}")] - Session(#[from] double_ratchets::SessionError), } #[derive(Error, Debug)] diff --git a/conversations/src/storage/migrations/001_initial_schema.sql b/conversations/src/storage/migrations/001_initial_schema.sql index 70b5359..5a97bfe 100644 --- a/conversations/src/storage/migrations/001_initial_schema.sql +++ b/conversations/src/storage/migrations/001_initial_schema.sql @@ -7,21 +7,3 @@ CREATE TABLE IF NOT EXISTS identity ( name TEXT NOT NULL, secret_key BLOB NOT NULL ); - --- Inbox ephemeral keys for handshakes -CREATE TABLE IF NOT EXISTS inbox_keys ( - public_key_hex TEXT PRIMARY KEY, - secret_key BLOB NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -); - --- Chat metadata -CREATE TABLE IF NOT EXISTS chats ( - chat_id TEXT PRIMARY KEY, - chat_type TEXT NOT NULL, - remote_public_key BLOB, - remote_address TEXT NOT NULL, - created_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index d82a421..d4d48d9 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -12,15 +12,6 @@ pub struct IdentityRecord { pub secret_key: [u8; 32], } -impl From<&Identity> for IdentityRecord { - fn from(identity: &Identity) -> Self { - Self { - name: identity.get_name().to_string(), - secret_key: identity.secret().DANGER_to_bytes(), - } - } -} - impl From for Identity { fn from(record: IdentityRecord) -> Self { let secret = PrivateKey::from(record.secret_key); diff --git a/storage/src/sqlite.rs b/storage/src/sqlite.rs index 8449532..4d42e9d 100644 --- a/storage/src/sqlite.rs +++ b/storage/src/sqlite.rs @@ -8,11 +8,8 @@ use crate::StorageError; /// Configuration for SQLite storage. #[derive(Debug, Clone)] pub enum StorageConfig { - /// In-memory database (isolated, for simple testing). + /// In-memory database (for testing). InMemory, - /// Shared in-memory database with a name (multiple connections share data). - /// Use this when you need multiple storage instances to share the same in-memory DB. - SharedInMemory(String), /// File-based SQLite database. File(String), /// SQLCipher encrypted database. @@ -32,17 +29,6 @@ impl SqliteDb { pub fn new(config: StorageConfig) -> Result { let conn = match config { StorageConfig::InMemory => Connection::open_in_memory()?, - StorageConfig::SharedInMemory(ref name) => { - // Use URI mode to create a shared in-memory database - // Multiple connections with the same name share the same data - let uri = format!("file:{}?mode=memory&cache=shared", name); - Connection::open_with_flags( - &uri, - rusqlite::OpenFlags::SQLITE_OPEN_URI - | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE - | rusqlite::OpenFlags::SQLITE_OPEN_CREATE, - )? - } StorageConfig::File(ref path) => Connection::open(path)?, StorageConfig::Encrypted { ref path, ref key } => { let conn = Connection::open(path)?; From 3673d730f3881941afe356e209d7b16f318ac295 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 15:28:45 +0800 Subject: [PATCH 07/18] refactor: use result wrapper for ffi --- conversations/src/api.rs | 17 +++++++++++++---- conversations/src/context.rs | 3 --- conversations/src/ffi/mod.rs | 1 + conversations/src/ffi/utils.rs | 13 +++++++++++++ conversations/src/lib.rs | 1 + 5 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 conversations/src/ffi/mod.rs create mode 100644 conversations/src/ffi/utils.rs diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 8ba81b9..6493980 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -18,6 +18,7 @@ use storage::StorageConfig; use crate::{ context::{Context, Introduction}, errors::ChatError, + ffi::utils::CResult, types::ContentData, }; @@ -65,15 +66,23 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { /// - db_path: Path to the SQLite database file /// /// # Returns -/// Opaque handle to the context. Must be freed with destroy_context() +/// CResult with context handle on success, or error string on failure #[ffi_export] pub fn create_context_with_storage( name: repr_c::String, db_path: repr_c::String, -) -> repr_c::Box { +) -> CResult, repr_c::String> { let config = StorageConfig::File(db_path.to_string()); - let ctx = Context::open(&*name, config).expect("failed to open context with storage"); - Box::new(ContextHandle(ctx)).into() + match Context::open(&*name, config) { + Ok(ctx) => CResult { + ok: Some(Box::new(ContextHandle(ctx)).into()), + err: None, + }, + Err(e) => CResult { + ok: None, + err: Some(e.to_string().into()), + }, + } } /// Returns the friendly name of the contexts installation. diff --git a/conversations/src/context.rs b/conversations/src/context.rs index fe410d0..f112053 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -18,9 +18,6 @@ pub use crate::inbox::Introduction; /// Error type for Context operations. #[derive(Debug, thiserror::Error)] pub enum ContextError { - #[error("chat error: {0}")] - Chat(#[from] ChatError), - #[error("storage error: {0}")] Storage(#[from] StorageError), } diff --git a/conversations/src/ffi/mod.rs b/conversations/src/ffi/mod.rs new file mode 100644 index 0000000..b5614dd --- /dev/null +++ b/conversations/src/ffi/mod.rs @@ -0,0 +1 @@ +pub mod utils; diff --git a/conversations/src/ffi/utils.rs b/conversations/src/ffi/utils.rs new file mode 100644 index 0000000..f50033f --- /dev/null +++ b/conversations/src/ffi/utils.rs @@ -0,0 +1,13 @@ +use safer_ffi::prelude::*; + +#[derive_ReprC] +#[repr(C)] +pub struct CResult { + pub ok: Option, + pub err: Option, +} + +#[ffi_export] +pub fn ffi_c_string_free(s: repr_c::String) { + drop(s); +} diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index e13c3fa..5490629 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -3,6 +3,7 @@ mod context; mod conversation; mod crypto; mod errors; +mod ffi; mod identity; mod inbox; mod proto; From fdacfae108092ae23d6f61b0bf529aa364c188ed Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 15:31:38 +0800 Subject: [PATCH 08/18] refactor: uniform storage error into chat error --- conversations/src/api.rs | 4 +++- conversations/src/context.rs | 11 ++--------- conversations/src/errors.rs | 4 ++++ conversations/src/ffi/utils.rs | 2 +- conversations/src/storage/mod.rs | 1 - 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 6493980..2fa1625 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -66,7 +66,9 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { /// - db_path: Path to the SQLite database file /// /// # Returns -/// CResult with context handle on success, or error string on failure +/// CResult with context handle on success, or error string on failure. +/// On success, the context handle must be freed with `destroy_context()` after usage. +/// On error, the error string must be freed with `destroy_string()` after usage. #[ffi_export] pub fn create_context_with_storage( name: repr_c::String, diff --git a/conversations/src/context.rs b/conversations/src/context.rs index f112053..bec37a5 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -8,20 +8,13 @@ use crate::{ identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, - storage::{ChatStorage, StorageError}, + storage::ChatStorage, types::{AddressedEnvelope, ContentData}, }; pub use crate::conversation::ConversationIdOwned; pub use crate::inbox::Introduction; -/// Error type for Context operations. -#[derive(Debug, thiserror::Error)] -pub enum ContextError { - #[error("storage error: {0}")] - Storage(#[from] StorageError), -} - // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. pub struct Context { @@ -37,7 +30,7 @@ impl Context { /// /// If an identity exists in storage, it will be restored. /// Otherwise, a new identity will be created with the given name and saved. - pub fn open(name: impl Into, config: StorageConfig) -> Result { + pub fn open(name: impl Into, config: StorageConfig) -> Result { let mut storage = ChatStorage::new(config)?; let name = name.into(); diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index d551960..f47004c 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -1,5 +1,7 @@ pub use thiserror::Error; +use storage::StorageError; + #[derive(Error, Debug)] pub enum ChatError { #[error("protocol error: {0:?}")] @@ -20,6 +22,8 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), + #[error("storage error: {0}")] + Storage(#[from] StorageError), } #[derive(Error, Debug)] diff --git a/conversations/src/ffi/utils.rs b/conversations/src/ffi/utils.rs index f50033f..0df0b3b 100644 --- a/conversations/src/ffi/utils.rs +++ b/conversations/src/ffi/utils.rs @@ -8,6 +8,6 @@ pub struct CResult { } #[ffi_export] -pub fn ffi_c_string_free(s: repr_c::String) { +pub fn destroy_string(s: repr_c::String) { drop(s); } diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index 7a32751..9364aeb 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -5,4 +5,3 @@ mod migrations; pub(crate) mod types; pub(crate) use db::ChatStorage; -pub(crate) use storage::StorageError; From 0e62c44b7ef507985c97d97bfcbcf78cbec7c9bb Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 15:48:44 +0800 Subject: [PATCH 09/18] fix: zeroize identity record --- Cargo.lock | 1 + conversations/Cargo.toml | 1 + conversations/src/storage/types.rs | 42 ++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bffaee7..4cd52f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,7 @@ dependencies = [ "tempfile", "thiserror", "x25519-dalek", + "zeroize", ] [[package]] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index b77d9f6..4ea9408 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -19,6 +19,7 @@ safer-ffi = "0.1.13" thiserror = "2.0.17" x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] } storage = { path = "../storage" } +zeroize = { version = "1.8.2", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index d4d48d9..8767324 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -1,10 +1,13 @@ //! Storage record types for serialization/deserialization. +use zeroize::{Zeroize, ZeroizeOnDrop}; + use crate::crypto::PrivateKey; use crate::identity::Identity; /// Record for storing identity (secret key). -#[derive(Debug)] +/// Implements ZeroizeOnDrop to securely clear secret key from memory. +#[derive(Debug, Zeroize, ZeroizeOnDrop)] pub struct IdentityRecord { /// The identity name. pub name: String, @@ -14,7 +17,42 @@ pub struct IdentityRecord { impl From for Identity { fn from(record: IdentityRecord) -> Self { + let name = record.name.clone(); let secret = PrivateKey::from(record.secret_key); - Identity::from_secret(record.name, secret) + Identity::from_secret(name, secret) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_record_zeroize() { + let secret_key = [0xAB_u8; 32]; + let mut record = IdentityRecord { + name: "test".to_string(), + secret_key, + }; + + // Get a pointer to the secret key before zeroizing + let ptr = record.secret_key.as_ptr(); + + // Manually zeroize (simulates what ZeroizeOnDrop does) + record.zeroize(); + + // Verify the memory is zeroed + // SAFETY: ptr still points to valid memory within record + unsafe { + let slice = std::slice::from_raw_parts(ptr, 32); + assert!(slice.iter().all(|&b| b == 0), "secret_key should be zeroed"); + } + + // Also verify via the struct field + assert!( + record.secret_key.iter().all(|&b| b == 0), + "secret_key field should be zeroed" + ); + assert!(record.name.is_empty(), "name should be cleared"); } } From c3e5f361bbdccae88234bb2470c050e92725b867 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Mon, 2 Mar 2026 11:38:44 +0800 Subject: [PATCH 10/18] fix: zeroize for secret keys in db operations --- conversations/src/api.rs | 7 ++++++- conversations/src/storage/db.rs | 35 +++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 2fa1625..75319f9 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -64,6 +64,7 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { /// # Parameters /// - name: Friendly name for the identity (used if creating new identity) /// - db_path: Path to the SQLite database file +/// - db_secret: Secret key for encrypting the database /// /// # Returns /// CResult with context handle on success, or error string on failure. @@ -73,8 +74,12 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { pub fn create_context_with_storage( name: repr_c::String, db_path: repr_c::String, + db_secret: repr_c::String, ) -> CResult, repr_c::String> { - let config = StorageConfig::File(db_path.to_string()); + let config = StorageConfig::Encrypted { + path: db_path.to_string(), + key: db_secret.to_string(), + }; match Context::open(&*name, config) { Ok(ctx) => CResult { ok: Some(Box::new(ContextHandle(ctx)).into()), diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 86f40fd..65750e9 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -1,6 +1,7 @@ //! Chat-specific storage implementation. use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; +use zeroize::Zeroize; use super::migrations; use super::types::IdentityRecord; @@ -32,18 +33,24 @@ impl ChatStorage { // ==================== Identity Operations ==================== /// Saves the identity (secret key). + /// + /// Note: The secret key bytes are explicitly zeroized after use to minimize + /// the time sensitive data remains in stack memory. pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { - self.db.connection().execute( + let mut secret_bytes = identity.secret().DANGER_to_bytes(); + let result = self.db.connection().execute( "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", - params![ - identity.get_name(), - identity.secret().DANGER_to_bytes().as_slice() - ], - )?; + params![identity.get_name(), secret_bytes.as_slice()], + ); + secret_bytes.zeroize(); + result?; Ok(()) } /// Loads the identity if it exists. + /// + /// Note: Secret key bytes are zeroized after being copied into IdentityRecord, + /// which handles its own zeroization via ZeroizeOnDrop. pub fn load_identity(&self) -> Result, StorageError> { let mut stmt = self .db @@ -57,10 +64,18 @@ impl ChatStorage { }); match result { - Ok((name, secret_key)) => { - let bytes: [u8; 32] = secret_key - .try_into() - .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; + Ok((name, mut secret_key_vec)) => { + let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into(); + let bytes = match bytes { + Ok(b) => b, + Err(_) => { + secret_key_vec.zeroize(); + return Err(StorageError::InvalidData( + "Invalid secret key length".into(), + )); + } + }; + secret_key_vec.zeroize(); let record = IdentityRecord { name, secret_key: bytes, From 030ab475bed29ebdf44c49276755a7b6063f5dde Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Mon, 2 Mar 2026 11:42:21 +0800 Subject: [PATCH 11/18] fix: transactional sql migration --- conversations/src/storage/db.rs | 4 ++-- conversations/src/storage/migrations.rs | 14 ++++++++------ storage/src/sqlite.rs | 7 +++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 65750e9..c855416 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -25,8 +25,8 @@ impl ChatStorage { } /// Applies all migrations and returns the storage instance. - fn run_migrations(db: SqliteDb) -> Result { - migrations::apply_migrations(db.connection())?; + fn run_migrations(mut db: SqliteDb) -> Result { + migrations::apply_migrations(db.connection_mut())?; Ok(Self { db }) } diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs index 41b3cb4..014bb96 100644 --- a/conversations/src/storage/migrations.rs +++ b/conversations/src/storage/migrations.rs @@ -1,6 +1,7 @@ //! Database migrations module. //! //! SQL migrations are embedded at compile time and applied in order. +//! Each migration is applied atomically within a transaction. use storage::{Connection, StorageError}; @@ -13,8 +14,9 @@ pub fn get_migrations() -> Vec<(&'static str, &'static str)> { } /// Applies all migrations to the database. +/// /// Uses a simple version tracking table to avoid re-running migrations. -pub fn apply_migrations(conn: &Connection) -> Result<(), StorageError> { +pub fn apply_migrations(conn: &mut Connection) -> Result<(), StorageError> { // Create migrations tracking table if it doesn't exist conn.execute_batch( "CREATE TABLE IF NOT EXISTS _migrations ( @@ -32,11 +34,11 @@ pub fn apply_migrations(conn: &Connection) -> Result<(), StorageError> { )?; if !already_applied { - // Apply migration - conn.execute_batch(sql)?; - - // Record migration - conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + // Apply migration and record it atomically in a transaction + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + tx.commit()?; } } diff --git a/storage/src/sqlite.rs b/storage/src/sqlite.rs index 4d42e9d..c6b00ab 100644 --- a/storage/src/sqlite.rs +++ b/storage/src/sqlite.rs @@ -66,6 +66,13 @@ impl SqliteDb { &self.conn } + /// Returns a mutable reference to the underlying connection. + /// + /// Use this for operations that require mutable access, such as transactions. + pub fn connection_mut(&mut self) -> &mut Connection { + &mut self.conn + } + /// Begins a transaction. pub fn transaction(&mut self) -> Result, StorageError> { Ok(self.conn.transaction()?) From 10a403e6faa04c8bc041379cbff240ec7cb6dfa0 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Mon, 2 Mar 2026 12:06:45 +0800 Subject: [PATCH 12/18] fix: remove destroy_string --- conversations/src/ffi/utils.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/conversations/src/ffi/utils.rs b/conversations/src/ffi/utils.rs index 0df0b3b..7989631 100644 --- a/conversations/src/ffi/utils.rs +++ b/conversations/src/ffi/utils.rs @@ -6,8 +6,3 @@ pub struct CResult { pub ok: Option, pub err: Option, } - -#[ffi_export] -pub fn destroy_string(s: repr_c::String) { - drop(s); -} From 5d87b1d19ac42bb98471d71cb7cb120b0ce2b591 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Thu, 5 Mar 2026 20:45:18 +0800 Subject: [PATCH 13/18] feat: db storage for inbox ephermeral keys --- conversations/src/context.rs | 22 +++++- conversations/src/inbox/handler.rs | 23 ++++-- conversations/src/storage/db.rs | 70 ++++++++++++++++++- conversations/src/storage/migrations.rs | 14 ++-- .../storage/migrations/002_ephemeral_keys.sql | 7 ++ conversations/src/storage/types.rs | 13 +++- 6 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 conversations/src/storage/migrations/002_ephemeral_keys.sql diff --git a/conversations/src/context.rs b/conversations/src/context.rs index bec37a5..9a1df9b 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,9 +1,11 @@ +use std::collections::HashMap; use std::rc::Rc; use storage::StorageConfig; use crate::{ conversation::{ConversationId, ConversationStore, Convo, Id}, + crypto::PrivateKey, errors::ChatError, identity::Identity, inbox::Inbox, @@ -44,7 +46,17 @@ impl Context { }; let identity = Rc::new(identity); - let inbox = Inbox::new(Rc::clone(&identity)); + let mut inbox = Inbox::new(Rc::clone(&identity)); + + // Restore ephemeral keys from storage + let stored_keys = storage.load_ephemeral_keys()?; + if !stored_keys.is_empty() { + let keys: HashMap = stored_keys + .into_iter() + .map(|record| (record.public_key_hex.clone(), PrivateKey::from(record.secret_key))) + .collect(); + inbox.restore_ephemeral_keys(keys); + } Ok(Self { _identity: identity, @@ -126,7 +138,8 @@ impl Context { &mut self, enc_payload: EncryptedPayload, ) -> Result, ChatError> { - let (convo, content) = self.inbox.handle_frame(enc_payload)?; + let (convo, content, consumed_key_hex) = self.inbox.handle_frame(enc_payload)?; + self.storage.remove_ephemeral_key(&consumed_key_hex)?; self.add_convo(convo); Ok(content) } @@ -145,7 +158,10 @@ impl Context { } pub fn create_intro_bundle(&mut self) -> Result, ChatError> { - Ok(self.inbox.create_intro_bundle().into()) + let (intro, public_key_hex, private_key) = self.inbox.create_intro_bundle(); + self.storage + .save_ephemeral_key(&public_key_hex, &private_key)?; + Ok(intro.into()) } fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { diff --git a/conversations/src/inbox/handler.rs b/conversations/src/inbox/handler.rs index 278ae16..6535c90 100644 --- a/conversations/src/inbox/handler.rs +++ b/conversations/src/inbox/handler.rs @@ -51,14 +51,23 @@ impl Inbox { } } - pub fn create_intro_bundle(&mut self) -> Introduction { + /// Restores ephemeral keys from storage into the in-memory map. + pub fn restore_ephemeral_keys(&mut self, keys: HashMap) { + self.ephemeral_keys = keys; + } + + /// Creates an intro bundle and returns the (public_key_hex, private_key) pair + /// so the caller can persist it. + pub fn create_intro_bundle(&mut self) -> (Introduction, String, PrivateKey) { let ephemeral = PrivateKey::random(); let ephemeral_key: PublicKey = (&ephemeral).into(); + let public_key_hex = hex::encode(ephemeral_key.as_bytes()); self.ephemeral_keys - .insert(hex::encode(ephemeral_key.as_bytes()), ephemeral); + .insert(public_key_hex.clone(), ephemeral.clone()); - Introduction::new(self.ident.secret(), ephemeral_key, OsRng) + let intro = Introduction::new(self.ident.secret(), ephemeral_key, OsRng); + (intro, public_key_hex, ephemeral) } pub fn invite_to_private_convo( @@ -114,10 +123,12 @@ impl Inbox { Ok((convo, payloads)) } + /// Handles an incoming inbox frame. Returns the created conversation, + /// optional content data, and the consumed ephemeral key hex (for storage cleanup). pub fn handle_frame( &mut self, enc_payload: EncryptedPayload, - ) -> Result<(Box, Option), ChatError> { + ) -> Result<(Box, Option, String), ChatError> { let handshake = Self::extract_payload(enc_payload)?; let header = handshake @@ -148,7 +159,7 @@ impl Inbox { None => return Err(ChatError::Protocol("expected contentData".into())), }; - Ok((Box::new(convo), Some(content))) + Ok((Box::new(convo), Some(content), key_index)) } } } @@ -244,7 +255,7 @@ mod tests { let raya_ident = Identity::new("raya"); let mut raya_inbox = Inbox::new(raya_ident.into()); - let bundle = raya_inbox.create_intro_bundle(); + let (bundle, _key_hex, _private_key) = raya_inbox.create_intro_bundle(); let (_, mut payloads) = saro_inbox .invite_to_private_convo(&bundle, "hello".as_bytes()) .unwrap(); diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index c855416..f8fb133 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -4,7 +4,8 @@ use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; use zeroize::Zeroize; use super::migrations; -use super::types::IdentityRecord; +use super::types::{EphemeralKeyRecord, IdentityRecord}; +use crate::crypto::PrivateKey; use crate::identity::Identity; /// Chat-specific storage operations. @@ -47,6 +48,73 @@ impl ChatStorage { Ok(()) } + // ==================== Ephemeral Key Operations ==================== + + /// Saves an ephemeral key pair to storage. + pub fn save_ephemeral_key( + &mut self, + public_key_hex: &str, + private_key: &PrivateKey, + ) -> Result<(), StorageError> { + let mut secret_bytes = private_key.DANGER_to_bytes(); + let result = self.db.connection().execute( + "INSERT OR REPLACE INTO ephemeral_keys (public_key_hex, secret_key) VALUES (?1, ?2)", + params![public_key_hex, secret_bytes.as_slice()], + ); + secret_bytes.zeroize(); + result?; + Ok(()) + } + + /// Loads all ephemeral keys from storage. + pub fn load_ephemeral_keys( + &self, + ) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT public_key_hex, secret_key FROM ephemeral_keys")?; + + let records = stmt + .query_map([], |row| { + let public_key_hex: String = row.get(0)?; + let secret_key: Vec = row.get(1)?; + Ok((public_key_hex, secret_key)) + })? + .collect::, _>>()?; + + let mut result = Vec::with_capacity(records.len()); + for (public_key_hex, mut secret_key_vec) in records { + let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into(); + let bytes = match bytes { + Ok(b) => b, + Err(_) => { + secret_key_vec.zeroize(); + return Err(StorageError::InvalidData( + "Invalid ephemeral secret key length".into(), + )); + } + }; + secret_key_vec.zeroize(); + result.push(EphemeralKeyRecord { + public_key_hex, + secret_key: bytes, + }); + } + Ok(result) + } + + /// Removes an ephemeral key from storage. + pub fn remove_ephemeral_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { + self.db.connection().execute( + "DELETE FROM ephemeral_keys WHERE public_key_hex = ?1", + params![public_key_hex], + )?; + Ok(()) + } + + // ==================== Identity Operations (continued) ==================== + /// Loads the identity if it exists. /// /// Note: Secret key bytes are zeroized after being copied into IdentityRecord, diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs index 014bb96..5de4737 100644 --- a/conversations/src/storage/migrations.rs +++ b/conversations/src/storage/migrations.rs @@ -7,10 +7,16 @@ use storage::{Connection, StorageError}; /// Embeds and returns all migration SQL files in order. pub fn get_migrations() -> Vec<(&'static str, &'static str)> { - vec![( - "001_initial_schema", - include_str!("migrations/001_initial_schema.sql"), - )] + vec![ + ( + "001_initial_schema", + include_str!("migrations/001_initial_schema.sql"), + ), + ( + "002_ephemeral_keys", + include_str!("migrations/002_ephemeral_keys.sql"), + ), + ] } /// Applies all migrations to the database. diff --git a/conversations/src/storage/migrations/002_ephemeral_keys.sql b/conversations/src/storage/migrations/002_ephemeral_keys.sql new file mode 100644 index 0000000..14ecbb0 --- /dev/null +++ b/conversations/src/storage/migrations/002_ephemeral_keys.sql @@ -0,0 +1,7 @@ +-- Ephemeral keys for inbox handshakes +-- Migration: 002_ephemeral_keys + +CREATE TABLE IF NOT EXISTS ephemeral_keys ( + public_key_hex TEXT PRIMARY KEY, + secret_key BLOB NOT NULL +); diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index 8767324..4a1ef72 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -17,12 +17,21 @@ pub struct IdentityRecord { impl From for Identity { fn from(record: IdentityRecord) -> Self { - let name = record.name.clone(); let secret = PrivateKey::from(record.secret_key); - Identity::from_secret(name, secret) + Identity::from_secret(record.name.clone(), secret) } } +/// Record for storing an ephemeral key pair. +/// Implements ZeroizeOnDrop to securely clear secret key from memory. +#[derive(Debug, Zeroize, ZeroizeOnDrop)] +pub struct EphemeralKeyRecord { + /// Hex-encoded public key (used as lookup key). + pub public_key_hex: String, + /// The secret key bytes (32 bytes). + pub secret_key: [u8; 32], +} + #[cfg(test)] mod tests { use super::*; From 3db9210ac3f4863a7f7a669c17a144380a19a3d3 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Thu, 12 Mar 2026 16:22:51 +0800 Subject: [PATCH 14/18] chore: remove in memory hashmap for ephemeral keys --- conversations/src/context.rs | 62 ++++++++++++++++++------ conversations/src/inbox/handler.rs | 75 ++++++++++++++-------------- conversations/src/storage/db.rs | 78 ++++++++++++++++++------------ conversations/src/storage/types.rs | 10 ---- 4 files changed, 134 insertions(+), 91 deletions(-) diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 9a1df9b..af63bb2 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,11 +1,9 @@ -use std::collections::HashMap; use std::rc::Rc; use storage::StorageConfig; use crate::{ conversation::{ConversationId, ConversationStore, Convo, Id}, - crypto::PrivateKey, errors::ChatError, identity::Identity, inbox::Inbox, @@ -23,7 +21,6 @@ pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, - #[allow(dead_code)] // Will be used for conversation persistence storage: ChatStorage, } @@ -46,17 +43,7 @@ impl Context { }; let identity = Rc::new(identity); - let mut inbox = Inbox::new(Rc::clone(&identity)); - - // Restore ephemeral keys from storage - let stored_keys = storage.load_ephemeral_keys()?; - if !stored_keys.is_empty() { - let keys: HashMap = stored_keys - .into_iter() - .map(|record| (record.public_key_hex.clone(), PrivateKey::from(record.secret_key))) - .collect(); - inbox.restore_ephemeral_keys(keys); - } + let inbox = Inbox::new(Rc::clone(&identity)); Ok(Self { _identity: identity, @@ -138,8 +125,18 @@ impl Context { &mut self, enc_payload: EncryptedPayload, ) -> Result, ChatError> { - let (convo, content, consumed_key_hex) = self.inbox.handle_frame(enc_payload)?; - self.storage.remove_ephemeral_key(&consumed_key_hex)?; + // Look up the ephemeral key from storage + let key_hex = Inbox::extract_ephemeral_key_hex(&enc_payload)?; + let ephemeral_key = self + .storage + .load_ephemeral_key(&key_hex)? + .ok_or(ChatError::UnknownEphemeralKey())?; + + let (convo, content) = self.inbox.handle_frame(&ephemeral_key, enc_payload)?; + + // Remove consumed ephemeral key from storage + self.storage.remove_ephemeral_key(&key_hex)?; + self.add_convo(convo); Ok(content) } @@ -268,4 +265,37 @@ mod tests { assert_eq!(pubkey1, pubkey2, "public key should persist"); assert_eq!(name1, name2, "name should persist"); } + + #[test] + fn ephemeral_key_persistence() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_ephemeral.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Create context and generate an intro bundle (creates ephemeral key) + let mut ctx1 = Context::open("alice", config.clone()).unwrap(); + let bundle1 = ctx1.create_intro_bundle().unwrap(); + + // Drop and reopen - ephemeral keys should be restored from db + drop(ctx1); + let mut ctx2 = Context::open("alice", config.clone()).unwrap(); + + // Use the intro bundle from before restart to start a conversation + let intro = Introduction::try_from(bundle1.as_slice()).unwrap(); + let mut bob = Context::new_with_name("bob"); + let (_, payloads) = bob.create_private_convo(&intro, b"hello after restart"); + + // Alice (ctx2) should be able to handle the payload using the persisted ephemeral key + let payload = payloads.first().unwrap(); + let content = ctx2 + .handle_payload(&payload.data) + .expect("should handle payload with persisted ephemeral key") + .expect("should have content"); + assert_eq!(content.data, b"hello after restart"); + assert!(content.is_new_convo); + } } diff --git a/conversations/src/inbox/handler.rs b/conversations/src/inbox/handler.rs index 6535c90..b733097 100644 --- a/conversations/src/inbox/handler.rs +++ b/conversations/src/inbox/handler.rs @@ -3,7 +3,6 @@ use chat_proto::logoschat::encryption::EncryptedPayload; use prost::Message; use prost::bytes::Bytes; use rand_core::OsRng; -use std::collections::HashMap; use std::rc::Rc; use crypto::{PrekeyBundle, SymmetricKey32}; @@ -25,7 +24,6 @@ fn delivery_address_for_installation(_: PublicKey) -> String { pub struct Inbox { ident: Rc, local_convo_id: String, - ephemeral_keys: HashMap, } impl std::fmt::Debug for Inbox { @@ -33,10 +31,6 @@ impl std::fmt::Debug for Inbox { f.debug_struct("Inbox") .field("ident", &self.ident) .field("convo_id", &self.local_convo_id) - .field( - "ephemeral_keys", - &format!("<{} keys>", self.ephemeral_keys.len()), - ) .finish() } } @@ -47,24 +41,16 @@ impl Inbox { Self { ident, local_convo_id, - ephemeral_keys: HashMap::::new(), } } - /// Restores ephemeral keys from storage into the in-memory map. - pub fn restore_ephemeral_keys(&mut self, keys: HashMap) { - self.ephemeral_keys = keys; - } - - /// Creates an intro bundle and returns the (public_key_hex, private_key) pair - /// so the caller can persist it. - pub fn create_intro_bundle(&mut self) -> (Introduction, String, PrivateKey) { + /// Creates an intro bundle and returns the Introduction along with the + /// generated ephemeral key pair (public_key_hex, private_key) for the caller to persist. + pub fn create_intro_bundle(&self) -> (Introduction, String, PrivateKey) { let ephemeral = PrivateKey::random(); let ephemeral_key: PublicKey = (&ephemeral).into(); let public_key_hex = hex::encode(ephemeral_key.as_bytes()); - self.ephemeral_keys - .insert(public_key_hex.clone(), ephemeral.clone()); let intro = Introduction::new(self.ident.secret(), ephemeral_key, OsRng); (intro, public_key_hex, ephemeral) @@ -123,22 +109,19 @@ impl Inbox { Ok((convo, payloads)) } - /// Handles an incoming inbox frame. Returns the created conversation, - /// optional content data, and the consumed ephemeral key hex (for storage cleanup). + /// Handles an incoming inbox frame. The caller must provide the ephemeral private key + /// looked up from storage. Returns the created conversation and optional content data. pub fn handle_frame( - &mut self, + &self, + ephemeral_key: &PrivateKey, enc_payload: EncryptedPayload, - ) -> Result<(Box, Option, String), ChatError> { + ) -> Result<(Box, Option), ChatError> { let handshake = Self::extract_payload(enc_payload)?; let header = handshake .header .ok_or(ChatError::UnexpectedPayload("InboxV1Header".into()))?; - // Get Ephemeral key used by the initator - let key_index = hex::encode(header.responder_ephemeral.as_ref()); - let ephemeral_key = self.lookup_ephemeral_key(&key_index)?; - // Perform handshake and decrypt frame let (seed_key, frame) = self.perform_handshake(ephemeral_key, header, handshake.payload)?; @@ -159,11 +142,29 @@ impl Inbox { None => return Err(ChatError::Protocol("expected contentData".into())), }; - Ok((Box::new(convo), Some(content), key_index)) + Ok((Box::new(convo), Some(content))) } } } + /// Extracts the ephemeral key hex from an incoming encrypted payload + /// so the caller can look it up from storage before calling handle_frame. + pub fn extract_ephemeral_key_hex( + enc_payload: &EncryptedPayload, + ) -> Result { + let Some(proto::Encryption::InboxHandshake(ref handshake)) = enc_payload.encryption else { + let got = format!("{:?}", enc_payload.encryption); + return Err(ChatError::ProtocolExpectation("inboxhandshake", got)); + }; + + let header = handshake + .header + .as_ref() + .ok_or(ChatError::UnexpectedPayload("InboxV1Header".into()))?; + + Ok(hex::encode(header.responder_ephemeral.as_ref())) + } + fn wrap_in_invite(payload: proto::EncryptedPayload) -> proto::InboxV1Frame { let invite = proto::InvitePrivateV1 { discriminator: "default".into(), @@ -225,12 +226,6 @@ impl Inbox { Ok(frame) } - fn lookup_ephemeral_key(&self, key: &str) -> Result<&PrivateKey, ChatError> { - self.ephemeral_keys - .get(key) - .ok_or(ChatError::UnknownEphemeralKey()) - } - pub fn inbox_identifier_for_key(pubkey: PublicKey) -> String { // TODO: Implement ID according to spec hex::encode(Blake2b512::digest(pubkey)) @@ -246,24 +241,34 @@ impl Id for Inbox { #[cfg(test)] mod tests { use super::*; + use crate::storage::ChatStorage; + use storage::StorageConfig; #[test] fn test_invite_privatev1_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + let saro_ident = Identity::new("saro"); let saro_inbox = Inbox::new(saro_ident.into()); let raya_ident = Identity::new("raya"); - let mut raya_inbox = Inbox::new(raya_ident.into()); + let raya_inbox = Inbox::new(raya_ident.into()); + + let (bundle, key_hex, private_key) = raya_inbox.create_intro_bundle(); + storage.save_ephemeral_key(&key_hex, &private_key).unwrap(); - let (bundle, _key_hex, _private_key) = raya_inbox.create_intro_bundle(); let (_, mut payloads) = saro_inbox .invite_to_private_convo(&bundle, "hello".as_bytes()) .unwrap(); let payload = payloads.remove(0); + // Look up ephemeral key from storage + let key_hex = Inbox::extract_ephemeral_key_hex(&payload.data).unwrap(); + let ephemeral_key = storage.load_ephemeral_key(&key_hex).unwrap().unwrap(); + // Test handle_frame with valid payload - let result = raya_inbox.handle_frame(payload.data); + let result = raya_inbox.handle_frame(&ephemeral_key, payload.data); assert!( result.is_ok(), diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index f8fb133..8eaefe9 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -4,7 +4,7 @@ use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; use zeroize::Zeroize; use super::migrations; -use super::types::{EphemeralKeyRecord, IdentityRecord}; +use super::types::IdentityRecord; use crate::crypto::PrivateKey; use crate::identity::Identity; @@ -66,42 +66,39 @@ impl ChatStorage { Ok(()) } - /// Loads all ephemeral keys from storage. - pub fn load_ephemeral_keys( + /// Loads a single ephemeral key by its public key hex. + pub fn load_ephemeral_key( &self, - ) -> Result, StorageError> { + public_key_hex: &str, + ) -> Result, StorageError> { let mut stmt = self .db .connection() - .prepare("SELECT public_key_hex, secret_key FROM ephemeral_keys")?; + .prepare("SELECT secret_key FROM ephemeral_keys WHERE public_key_hex = ?1")?; - let records = stmt - .query_map([], |row| { - let public_key_hex: String = row.get(0)?; - let secret_key: Vec = row.get(1)?; - Ok((public_key_hex, secret_key)) - })? - .collect::, _>>()?; + let result = stmt.query_row(params![public_key_hex], |row| { + let secret_key: Vec = row.get(0)?; + Ok(secret_key) + }); - let mut result = Vec::with_capacity(records.len()); - for (public_key_hex, mut secret_key_vec) in records { - let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into(); - let bytes = match bytes { - Ok(b) => b, - Err(_) => { - secret_key_vec.zeroize(); - return Err(StorageError::InvalidData( - "Invalid ephemeral secret key length".into(), - )); - } - }; - secret_key_vec.zeroize(); - result.push(EphemeralKeyRecord { - public_key_hex, - secret_key: bytes, - }); + match result { + Ok(mut secret_key_vec) => { + let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into(); + let bytes = match bytes { + Ok(b) => b, + Err(_) => { + secret_key_vec.zeroize(); + return Err(StorageError::InvalidData( + "Invalid ephemeral secret key length".into(), + )); + } + }; + secret_key_vec.zeroize(); + Ok(Some(PrivateKey::from(bytes))) + } + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), } - Ok(result) } /// Removes an ephemeral key from storage. @@ -176,4 +173,25 @@ mod tests { let loaded = storage.load_identity().unwrap().unwrap(); assert_eq!(loaded.public_key(), pubkey); } + + #[test] + fn test_ephemeral_key_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + let key1 = PrivateKey::random(); + let pub1: crate::crypto::PublicKey = (&key1).into(); + let hex1 = hex::encode(pub1.as_bytes()); + + // Initially not found + assert!(storage.load_ephemeral_key(&hex1).unwrap().is_none()); + + // Save and load + storage.save_ephemeral_key(&hex1, &key1).unwrap(); + let loaded = storage.load_ephemeral_key(&hex1).unwrap().unwrap(); + assert_eq!(loaded.DANGER_to_bytes(), key1.DANGER_to_bytes()); + + // Remove and verify gone + storage.remove_ephemeral_key(&hex1).unwrap(); + assert!(storage.load_ephemeral_key(&hex1).unwrap().is_none()); + } } diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index 4a1ef72..c34f9be 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -22,16 +22,6 @@ impl From for Identity { } } -/// Record for storing an ephemeral key pair. -/// Implements ZeroizeOnDrop to securely clear secret key from memory. -#[derive(Debug, Zeroize, ZeroizeOnDrop)] -pub struct EphemeralKeyRecord { - /// Hex-encoded public key (used as lookup key). - pub public_key_hex: String, - /// The secret key bytes (32 bytes). - pub secret_key: [u8; 32], -} - #[cfg(test)] mod tests { use super::*; From 330059fd2dc192f2f1a4c696df11d3716622c23a Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 13 Mar 2026 10:15:06 +0800 Subject: [PATCH 15/18] feat: persist conversation store --- conversations/src/context.rs | 42 +++++++++++ conversations/src/conversation.rs | 3 + conversations/src/conversation/group_test.rs | 4 + conversations/src/conversation/privatev1.rs | 4 + conversations/src/storage/db.rs | 74 ++++++++++++++++++- conversations/src/storage/migrations.rs | 4 + .../storage/migrations/003_conversations.sql | 9 +++ conversations/src/storage/types.rs | 8 ++ 8 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 conversations/src/storage/migrations/003_conversations.sql diff --git a/conversations/src/context.rs b/conversations/src/context.rs index af63bb2..46378f6 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -162,6 +162,12 @@ impl Context { } fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { + // Persist conversation metadata to storage + let _ = self.storage.save_conversation( + convo.id(), + &convo.remote_id(), + convo.convo_type(), + ); self.store.insert_convo(convo) } @@ -298,4 +304,40 @@ mod tests { assert_eq!(content.data, b"hello after restart"); assert!(content.is_new_convo); } + + #[test] + fn conversation_metadata_persistence() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_convo_meta.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Create context, establish a conversation + let mut alice = Context::open("alice", config.clone()).unwrap(); + let mut bob = Context::new_with_name("bob"); + + let bundle = alice.create_intro_bundle().unwrap(); + let intro = Introduction::try_from(bundle.as_slice()).unwrap(); + let (_, payloads) = bob.create_private_convo(&intro, b"hi"); + + let payload = payloads.first().unwrap(); + let content = alice.handle_payload(&payload.data).unwrap().unwrap(); + assert!(content.is_new_convo); + + // Verify conversation metadata was persisted + let convos = alice.storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 1); + assert_eq!(convos[0].convo_type, "private_v1"); + assert!(!convos[0].local_convo_id.is_empty()); + assert!(!convos[0].remote_convo_id.is_empty()); + + // Drop and reopen - metadata should still be there + drop(alice); + let alice2 = Context::open("alice", config).unwrap(); + let convos = alice2.storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 1, "conversation metadata should persist"); + } } diff --git a/conversations/src/conversation.rs b/conversations/src/conversation.rs index a148c5a..329993b 100644 --- a/conversations/src/conversation.rs +++ b/conversations/src/conversation.rs @@ -27,6 +27,9 @@ pub trait Convo: Id + Debug { ) -> Result, ChatError>; fn remote_id(&self) -> String; + + /// Returns the conversation type identifier for storage. + fn convo_type(&self) -> &str; } pub struct ConversationStore { diff --git a/conversations/src/conversation/group_test.rs b/conversations/src/conversation/group_test.rs index e77984f..0ce4084 100644 --- a/conversations/src/conversation/group_test.rs +++ b/conversations/src/conversation/group_test.rs @@ -38,4 +38,8 @@ impl Convo for GroupTestConvo { fn remote_id(&self) -> String { self.id().to_string() } + + fn convo_type(&self) -> &str { + "group_test" + } } diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index 0b8042e..5273bf1 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -209,6 +209,10 @@ impl Convo for PrivateV1Convo { fn remote_id(&self) -> String { self.remote_convo_id.clone() } + + fn convo_type(&self) -> &str { + "private_v1" + } } impl Debug for PrivateV1Convo { diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 8eaefe9..b950e7b 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -4,7 +4,7 @@ use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; use zeroize::Zeroize; use super::migrations; -use super::types::IdentityRecord; +use super::types::{ConversationRecord, IdentityRecord}; use crate::crypto::PrivateKey; use crate::identity::Identity; @@ -110,6 +110,50 @@ impl ChatStorage { Ok(()) } + // ==================== Conversation Operations ==================== + + /// Saves conversation metadata. + pub fn save_conversation( + &mut self, + local_convo_id: &str, + remote_convo_id: &str, + convo_type: &str, + ) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO conversations (local_convo_id, remote_convo_id, convo_type) VALUES (?1, ?2, ?3)", + params![local_convo_id, remote_convo_id, convo_type], + )?; + Ok(()) + } + + /// Loads all conversation records. + pub fn load_conversations(&self) -> Result, StorageError> { + let mut stmt = self.db.connection().prepare( + "SELECT local_convo_id, remote_convo_id, convo_type FROM conversations", + )?; + + let records = stmt + .query_map([], |row| { + Ok(ConversationRecord { + local_convo_id: row.get(0)?, + remote_convo_id: row.get(1)?, + convo_type: row.get(2)?, + }) + })? + .collect::, _>>()?; + + Ok(records) + } + + /// Removes a conversation by its local ID. + pub fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> { + self.db.connection().execute( + "DELETE FROM conversations WHERE local_convo_id = ?1", + params![local_convo_id], + )?; + Ok(()) + } + // ==================== Identity Operations (continued) ==================== /// Loads the identity if it exists. @@ -194,4 +238,32 @@ mod tests { storage.remove_ephemeral_key(&hex1).unwrap(); assert!(storage.load_ephemeral_key(&hex1).unwrap().is_none()); } + + #[test] + fn test_conversation_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + // Initially empty + let convos = storage.load_conversations().unwrap(); + assert!(convos.is_empty()); + + // Save conversations + storage + .save_conversation("local_1", "remote_1", "private_v1") + .unwrap(); + storage + .save_conversation("local_2", "remote_2", "private_v1") + .unwrap(); + + let convos = storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 2); + + // Remove one + storage.remove_conversation("local_1").unwrap(); + let convos = storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 1); + assert_eq!(convos[0].local_convo_id, "local_2"); + assert_eq!(convos[0].remote_convo_id, "remote_2"); + assert_eq!(convos[0].convo_type, "private_v1"); + } } diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs index 5de4737..848122d 100644 --- a/conversations/src/storage/migrations.rs +++ b/conversations/src/storage/migrations.rs @@ -16,6 +16,10 @@ pub fn get_migrations() -> Vec<(&'static str, &'static str)> { "002_ephemeral_keys", include_str!("migrations/002_ephemeral_keys.sql"), ), + ( + "003_conversations", + include_str!("migrations/003_conversations.sql"), + ), ] } diff --git a/conversations/src/storage/migrations/003_conversations.sql b/conversations/src/storage/migrations/003_conversations.sql new file mode 100644 index 0000000..71da87f --- /dev/null +++ b/conversations/src/storage/migrations/003_conversations.sql @@ -0,0 +1,9 @@ +-- Conversations metadata +-- Migration: 003_conversations + +CREATE TABLE IF NOT EXISTS conversations ( + local_convo_id TEXT PRIMARY KEY, + remote_convo_id TEXT NOT NULL, + convo_type TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index c34f9be..58c21ce 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -22,6 +22,14 @@ impl From for Identity { } } +/// Record for storing conversation metadata. +#[derive(Debug)] +pub struct ConversationRecord { + pub local_convo_id: String, + pub remote_convo_id: String, + pub convo_type: String, +} + #[cfg(test)] mod tests { use super::*; From bc9397d2e0fba7da1fa24b2557927c7193939201 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 13 Mar 2026 10:30:45 +0800 Subject: [PATCH 16/18] feat: wire with the double ratchet storage --- conversations/src/context.rs | 130 +++++++++++++++----- conversations/src/conversation.rs | 6 + conversations/src/conversation/privatev1.rs | 19 +++ double-ratchets/src/storage/db.rs | 6 + 4 files changed, 132 insertions(+), 29 deletions(-) diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 46378f6..f6e9cdd 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,9 +1,10 @@ use std::rc::Rc; +use double_ratchets::{RatchetState, RatchetStorage}; use storage::StorageConfig; use crate::{ - conversation::{ConversationId, ConversationStore, Convo, Id}, + conversation::{ConversationId, ConversationStore, Convo, Id, PrivateV1Convo}, errors::ChatError, identity::Identity, inbox::Inbox, @@ -22,6 +23,7 @@ pub struct Context { store: ConversationStore, inbox: Inbox, storage: ChatStorage, + ratchet_storage: RatchetStorage, } impl Context { @@ -30,7 +32,8 @@ impl Context { /// If an identity exists in storage, it will be restored. /// Otherwise, a new identity will be created with the given name and saved. pub fn open(name: impl Into, config: StorageConfig) -> Result { - let mut storage = ChatStorage::new(config)?; + let mut storage = ChatStorage::new(config.clone())?; + let ratchet_storage = RatchetStorage::from_config(config)?; let name = name.into(); // Load or create identity @@ -45,11 +48,31 @@ impl Context { let identity = Rc::new(identity); let inbox = Inbox::new(Rc::clone(&identity)); + // Restore persisted conversations + let mut store = ConversationStore::new(); + let conversation_records = storage.load_conversations()?; + for record in conversation_records { + let convo: Box = match record.convo_type.as_str() { + "private_v1" => { + let dr_state: RatchetState = + ratchet_storage.load(&record.local_convo_id)?; + Box::new(PrivateV1Convo::from_stored( + record.local_convo_id, + record.remote_convo_id, + dr_state, + )) + } + _ => continue, // Skip unknown conversation types + }; + store.insert_convo(convo); + } + Ok(Self { _identity: identity, - store: ConversationStore::new(), + store, inbox, storage, + ratchet_storage, }) } @@ -93,16 +116,18 @@ impl Context { convo_id: ConversationId, content: &[u8], ) -> Result, ChatError> { - // Lookup convo by id - let convo = self.get_convo_mut(convo_id)?; + let convo = self + .store + .get_mut(convo_id) + .ok_or_else(|| ChatError::NoConvo(convo_id.into()))?; - // Generate encrypted payloads let payloads = convo.send_message(content)?; + let remote_id = convo.remote_id(); + convo.save_ratchet_state(&mut self.ratchet_storage)?; - // Attach conversation_ids to Envelopes Ok(payloads .into_iter() - .map(|p| p.into_envelope(convo.remote_id())) + .map(|p| p.into_envelope(remote_id.clone())) .collect()) } @@ -151,7 +176,12 @@ impl Context { return Err(ChatError::Protocol("convo id not found".into())); }; - convo.handle_frame(enc_payload) + let result = convo.handle_frame(enc_payload)?; + + // Persist updated ratchet state + convo.save_ratchet_state(&mut self.ratchet_storage)?; + + Ok(result) } pub fn create_intro_bundle(&mut self) -> Result, ChatError> { @@ -162,21 +192,16 @@ impl Context { } fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { - // Persist conversation metadata to storage + // Persist conversation metadata and ratchet state let _ = self.storage.save_conversation( convo.id(), &convo.remote_id(), convo.convo_type(), ); + let _ = convo.save_ratchet_state(&mut self.ratchet_storage); self.store.insert_convo(convo) } - // Returns a mutable reference to a Convo for a given ConvoId - fn get_convo_mut(&mut self, convo_id: ConversationId) -> Result<&mut dyn Convo, ChatError> { - self.store - .get_mut(convo_id) - .ok_or_else(|| ChatError::NoConvo(convo_id.into())) - } } #[cfg(test)] @@ -247,7 +272,6 @@ mod tests { #[test] fn identity_persistence() { - // Use file-based storage to test real persistence let dir = tempfile::tempdir().unwrap(); let db_path = dir .path() @@ -256,18 +280,15 @@ mod tests { .to_string(); let config = StorageConfig::File(db_path); - // Create context - this should create and save a new identity let ctx1 = Context::open("alice", config.clone()).unwrap(); let pubkey1 = ctx1._identity.public_key(); let name1 = ctx1.installation_name().to_string(); - // Drop and reopen - should load the same identity drop(ctx1); let ctx2 = Context::open("alice", config).unwrap(); let pubkey2 = ctx2._identity.public_key(); let name2 = ctx2.installation_name().to_string(); - // Identity should be the same assert_eq!(pubkey1, pubkey2, "public key should persist"); assert_eq!(name1, name2, "name should persist"); } @@ -282,20 +303,16 @@ mod tests { .to_string(); let config = StorageConfig::File(db_path); - // Create context and generate an intro bundle (creates ephemeral key) let mut ctx1 = Context::open("alice", config.clone()).unwrap(); let bundle1 = ctx1.create_intro_bundle().unwrap(); - // Drop and reopen - ephemeral keys should be restored from db drop(ctx1); let mut ctx2 = Context::open("alice", config.clone()).unwrap(); - // Use the intro bundle from before restart to start a conversation let intro = Introduction::try_from(bundle1.as_slice()).unwrap(); let mut bob = Context::new_with_name("bob"); let (_, payloads) = bob.create_private_convo(&intro, b"hello after restart"); - // Alice (ctx2) should be able to handle the payload using the persisted ephemeral key let payload = payloads.first().unwrap(); let content = ctx2 .handle_payload(&payload.data) @@ -315,7 +332,6 @@ mod tests { .to_string(); let config = StorageConfig::File(db_path); - // Create context, establish a conversation let mut alice = Context::open("alice", config.clone()).unwrap(); let mut bob = Context::new_with_name("bob"); @@ -327,17 +343,73 @@ mod tests { let content = alice.handle_payload(&payload.data).unwrap().unwrap(); assert!(content.is_new_convo); - // Verify conversation metadata was persisted let convos = alice.storage.load_conversations().unwrap(); assert_eq!(convos.len(), 1); assert_eq!(convos[0].convo_type, "private_v1"); - assert!(!convos[0].local_convo_id.is_empty()); - assert!(!convos[0].remote_convo_id.is_empty()); - // Drop and reopen - metadata should still be there drop(alice); let alice2 = Context::open("alice", config).unwrap(); let convos = alice2.storage.load_conversations().unwrap(); assert_eq!(convos.len(), 1, "conversation metadata should persist"); } + + #[test] + fn conversation_full_persistence() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_full_persist.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Alice and Bob establish a conversation + let mut alice = Context::open("alice", config.clone()).unwrap(); + let mut bob = Context::new_with_name("bob"); + + let bundle = alice.create_intro_bundle().unwrap(); + let intro = Introduction::try_from(bundle.as_slice()).unwrap(); + let (bob_convo_id, payloads) = bob.create_private_convo(&intro, b"hello"); + + let payload = payloads.first().unwrap(); + let content = alice.handle_payload(&payload.data).unwrap().unwrap(); + let alice_convo_id = content.conversation_id; + + // Exchange a few messages to advance ratchet state + let payloads = alice.send_content(&alice_convo_id, b"reply 1").unwrap(); + let payload = payloads.first().unwrap(); + bob.handle_payload(&payload.data).unwrap().unwrap(); + + let payloads = bob.send_content(&bob_convo_id, b"reply 2").unwrap(); + let payload = payloads.first().unwrap(); + alice.handle_payload(&payload.data).unwrap().unwrap(); + + // Drop Alice and reopen - conversation should survive + drop(alice); + let mut alice2 = Context::open("alice", config).unwrap(); + + // Verify conversation was restored + let convo_ids = alice2.list_conversations().unwrap(); + assert_eq!(convo_ids.len(), 1); + + // Bob sends a new message - Alice should be able to decrypt after restart + let payloads = bob.send_content(&bob_convo_id, b"after restart").unwrap(); + let payload = payloads.first().unwrap(); + let content = alice2 + .handle_payload(&payload.data) + .expect("should decrypt after restart") + .expect("should have content"); + assert_eq!(content.data, b"after restart"); + + // Alice can also send back + let payloads = alice2 + .send_content(&alice_convo_id, b"alice after restart") + .unwrap(); + let payload = payloads.first().unwrap(); + let content = bob + .handle_payload(&payload.data) + .unwrap() + .expect("bob should receive"); + assert_eq!(content.data, b"alice after restart"); + } } diff --git a/conversations/src/conversation.rs b/conversations/src/conversation.rs index 329993b..db4e76d 100644 --- a/conversations/src/conversation.rs +++ b/conversations/src/conversation.rs @@ -4,6 +4,7 @@ use std::sync::Arc; pub use crate::errors::ChatError; use crate::types::{AddressedEncryptedPayload, ContentData}; +use double_ratchets::RatchetStorage; pub type ConversationId<'a> = &'a str; pub type ConversationIdOwned = Arc; @@ -30,6 +31,11 @@ pub trait Convo: Id + Debug { /// Returns the conversation type identifier for storage. fn convo_type(&self) -> &str; + + /// Persists ratchet state to storage. Default is no-op. + fn save_ratchet_state(&self, _storage: &mut RatchetStorage) -> Result<(), ChatError> { + Ok(()) + } } pub struct ConversationStore { diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index 5273bf1..3cd506d 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -18,6 +18,7 @@ use crate::{ types::{AddressedEncryptedPayload, ContentData}, utils::timestamp_millis, }; +use double_ratchets::RatchetStorage; // Represents the potential participant roles in this Conversation enum Role { @@ -77,6 +78,19 @@ impl PrivateV1Convo { } } + /// Reconstructs a PrivateV1Convo from persisted metadata and ratchet state. + pub fn from_stored( + local_convo_id: String, + remote_convo_id: String, + dr_state: RatchetState, + ) -> Self { + Self { + local_convo_id, + remote_convo_id, + dr_state, + } + } + pub fn new_responder(seed_key: SymmetricKey32, dh_self: &PrivateKey) -> Self { let base_convo_id = BaseConvoId::new(&seed_key); let local_convo_id = base_convo_id.id_for_participant(Role::Responder); @@ -213,6 +227,11 @@ impl Convo for PrivateV1Convo { fn convo_type(&self) -> &str { "private_v1" } + + fn save_ratchet_state(&self, storage: &mut RatchetStorage) -> Result<(), ChatError> { + storage.save(&self.local_convo_id, &self.dr_state)?; + Ok(()) + } } impl Debug for PrivateV1Convo { diff --git a/double-ratchets/src/storage/db.rs b/double-ratchets/src/storage/db.rs index 43b3f4b..c69d813 100644 --- a/double-ratchets/src/storage/db.rs +++ b/double-ratchets/src/storage/db.rs @@ -59,6 +59,12 @@ impl RatchetStorage { Self::run_migration(db) } + /// Creates a ratchet storage from a generic storage configuration. + pub fn from_config(config: storage::StorageConfig) -> Result { + let db = SqliteDb::new(config)?; + Self::run_migration(db) + } + /// Creates a new ratchet storage with the given database. fn run_migration(db: SqliteDb) -> Result { // Initialize schema From 44949b00431845b4cf8a9e2c0a1561ab8cb1e7c1 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 13 Mar 2026 10:38:00 +0800 Subject: [PATCH 17/18] feat: remove conversation store --- conversations/src/context.rs | 82 ++++++++------------ conversations/src/conversation.rs | 36 --------- conversations/src/conversation/group_test.rs | 45 ----------- conversations/src/storage/db.rs | 34 ++++++++ 4 files changed, 65 insertions(+), 132 deletions(-) delete mode 100644 conversations/src/conversation/group_test.rs diff --git a/conversations/src/context.rs b/conversations/src/context.rs index f6e9cdd..e695c28 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,10 +1,11 @@ use std::rc::Rc; +use std::sync::Arc; use double_ratchets::{RatchetState, RatchetStorage}; use storage::StorageConfig; use crate::{ - conversation::{ConversationId, ConversationStore, Convo, Id, PrivateV1Convo}, + conversation::{ConversationId, Convo, Id, PrivateV1Convo}, errors::ChatError, identity::Identity, inbox::Inbox, @@ -20,7 +21,6 @@ pub use crate::inbox::Introduction; // Ctx manages lifetimes of objects to process and generate payloads. pub struct Context { _identity: Rc, - store: ConversationStore, inbox: Inbox, storage: ChatStorage, ratchet_storage: RatchetStorage, @@ -48,28 +48,8 @@ impl Context { let identity = Rc::new(identity); let inbox = Inbox::new(Rc::clone(&identity)); - // Restore persisted conversations - let mut store = ConversationStore::new(); - let conversation_records = storage.load_conversations()?; - for record in conversation_records { - let convo: Box = match record.convo_type.as_str() { - "private_v1" => { - let dr_state: RatchetState = - ratchet_storage.load(&record.local_convo_id)?; - Box::new(PrivateV1Convo::from_stored( - record.local_convo_id, - record.remote_convo_id, - dr_state, - )) - } - _ => continue, // Skip unknown conversation types - }; - store.insert_convo(convo); - } - Ok(Self { _identity: identity, - store, inbox, storage, ratchet_storage, @@ -103,12 +83,16 @@ impl Context { .map(|p| p.into_envelope(remote_id.clone())) .collect(); - let convo_id = self.add_convo(Box::new(convo)); + let convo_id = self.persist_convo(&convo); (convo_id, payload_bytes) } pub fn list_conversations(&self) -> Result, ChatError> { - Ok(self.store.conversation_ids()) + let records = self.storage.load_conversations()?; + Ok(records + .into_iter() + .map(|r| Arc::from(r.local_convo_id.as_str())) + .collect()) } pub fn send_content( @@ -116,10 +100,7 @@ impl Context { convo_id: ConversationId, content: &[u8], ) -> Result, ChatError> { - let convo = self - .store - .get_mut(convo_id) - .ok_or_else(|| ChatError::NoConvo(convo_id.into()))?; + let mut convo = self.load_convo(convo_id)?; let payloads = convo.send_message(content)?; let remote_id = convo.remote_id(); @@ -140,7 +121,7 @@ impl Context { let enc = EncryptedPayload::decode(env.payload)?; match convo_id { c if c == self.inbox.id() => self.dispatch_to_inbox(enc), - c if self.store.has(&c) => self.dispatch_to_convo(&c, enc), + c if self.storage.has_conversation(&c)? => self.dispatch_to_convo(&c, enc), _ => Ok(None), } } @@ -162,7 +143,7 @@ impl Context { // Remove consumed ephemeral key from storage self.storage.remove_ephemeral_key(&key_hex)?; - self.add_convo(convo); + self.persist_convo(convo.as_ref()); Ok(content) } @@ -172,13 +153,9 @@ impl Context { convo_id: ConversationId, enc_payload: EncryptedPayload, ) -> Result, ChatError> { - let Some(convo) = self.store.get_mut(convo_id) else { - return Err(ChatError::Protocol("convo id not found".into())); - }; + let mut convo = self.load_convo(convo_id)?; let result = convo.handle_frame(enc_payload)?; - - // Persist updated ratchet state convo.save_ratchet_state(&mut self.ratchet_storage)?; Ok(result) @@ -191,34 +168,37 @@ impl Context { Ok(intro.into()) } - fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { - // Persist conversation metadata and ratchet state + /// Loads a conversation from DB by constructing it from metadata + ratchet state. + fn load_convo(&self, convo_id: ConversationId) -> Result { + let record = self + .storage + .load_conversation(convo_id)? + .ok_or_else(|| ChatError::NoConvo(convo_id.into()))?; + + let dr_state: RatchetState = self.ratchet_storage.load(&record.local_convo_id)?; + + Ok(PrivateV1Convo::from_stored( + record.local_convo_id, + record.remote_convo_id, + dr_state, + )) + } + + /// Persists a conversation's metadata and ratchet state to DB. + fn persist_convo(&mut self, convo: &dyn Convo) -> ConversationIdOwned { let _ = self.storage.save_conversation( convo.id(), &convo.remote_id(), convo.convo_type(), ); let _ = convo.save_ratchet_state(&mut self.ratchet_storage); - self.store.insert_convo(convo) + Arc::from(convo.id()) } - } #[cfg(test)] mod tests { use super::*; - use crate::conversation::GroupTestConvo; - - #[test] - fn convo_store_get() { - let mut store: ConversationStore = ConversationStore::new(); - - let new_convo = GroupTestConvo::new(); - let convo_id = store.insert_convo(Box::new(new_convo)); - - let convo = store.get_mut(&convo_id).ok_or(0); - convo.unwrap(); - } fn send_and_verify( sender: &mut Context, diff --git a/conversations/src/conversation.rs b/conversations/src/conversation.rs index db4e76d..4e15373 100644 --- a/conversations/src/conversation.rs +++ b/conversations/src/conversation.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; @@ -38,42 +37,7 @@ pub trait Convo: Id + Debug { } } -pub struct ConversationStore { - conversations: HashMap, Box>, -} - -impl ConversationStore { - pub fn new() -> Self { - Self { - conversations: HashMap::new(), - } - } - - pub fn insert_convo(&mut self, conversation: Box) -> ConversationIdOwned { - let key: ConversationIdOwned = Arc::from(conversation.id()); - self.conversations.insert(key.clone(), conversation); - key - } - - pub fn has(&self, id: ConversationId) -> bool { - self.conversations.contains_key(id) - } - - pub fn get_mut(&mut self, id: &str) -> Option<&mut (dyn Convo + '_)> { - Some(self.conversations.get_mut(id)?.as_mut()) - } - - #[allow(dead_code)] - pub fn conversation_ids(&self) -> Vec { - self.conversations.keys().cloned().collect() - } -} - -#[cfg(test)] -mod group_test; mod privatev1; use chat_proto::logoschat::encryption::EncryptedPayload; -#[cfg(test)] -pub(crate) use group_test::GroupTestConvo; pub use privatev1::PrivateV1Convo; diff --git a/conversations/src/conversation/group_test.rs b/conversations/src/conversation/group_test.rs deleted file mode 100644 index 0ce4084..0000000 --- a/conversations/src/conversation/group_test.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::{ - conversation::{ChatError, ConversationId, Convo, Id}, - proto::EncryptedPayload, - types::{AddressedEncryptedPayload, ContentData}, -}; - -#[derive(Debug)] -pub struct GroupTestConvo {} - -impl GroupTestConvo { - pub fn new() -> Self { - Self {} - } -} - -impl Id for GroupTestConvo { - fn id(&self) -> ConversationId<'_> { - // implementation - "grouptest" - } -} - -impl Convo for GroupTestConvo { - fn send_message( - &mut self, - _content: &[u8], - ) -> Result, ChatError> { - Ok(vec![]) - } - - fn handle_frame( - &mut self, - _encoded_payload: EncryptedPayload, - ) -> Result, ChatError> { - Ok(None) - } - - fn remote_id(&self) -> String { - self.id().to_string() - } - - fn convo_type(&self) -> &str { - "group_test" - } -} diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index b950e7b..6530224 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -126,6 +126,40 @@ impl ChatStorage { Ok(()) } + /// Checks if a conversation exists by its local ID. + pub fn has_conversation(&self, local_convo_id: &str) -> Result { + let exists: bool = self.db.connection().query_row( + "SELECT EXISTS(SELECT 1 FROM conversations WHERE local_convo_id = ?1)", + params![local_convo_id], + |row| row.get(0), + )?; + Ok(exists) + } + + /// Loads a single conversation record by its local ID. + pub fn load_conversation( + &self, + local_convo_id: &str, + ) -> Result, StorageError> { + let mut stmt = self.db.connection().prepare( + "SELECT local_convo_id, remote_convo_id, convo_type FROM conversations WHERE local_convo_id = ?1", + )?; + + let result = stmt.query_row(params![local_convo_id], |row| { + Ok(ConversationRecord { + local_convo_id: row.get(0)?, + remote_convo_id: row.get(1)?, + convo_type: row.get(2)?, + }) + }); + + match result { + Ok(record) => Ok(Some(record)), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + /// Loads all conversation records. pub fn load_conversations(&self) -> Result, StorageError> { let mut stmt = self.db.connection().prepare( From c4566bb7ce147779c372a3e3c37648993bbc2a00 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Mar 2026 15:31:47 +0800 Subject: [PATCH 18/18] chore: fix conversation type not used --- core/conversations/src/context.rs | 7 +++++++ core/conversations/src/errors.rs | 2 ++ core/conversations/src/storage.rs | 19 ++++++++++--------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/core/conversations/src/context.rs b/core/conversations/src/context.rs index 23929e5..e79a8c7 100644 --- a/core/conversations/src/context.rs +++ b/core/conversations/src/context.rs @@ -175,6 +175,13 @@ impl Context { .load_conversation(convo_id)? .ok_or_else(|| ChatError::NoConvo(convo_id.into()))?; + if record.convo_type != "private_v1" { + return Err(ChatError::BadBundleValue(format!( + "unsupported conversation type: {}", + record.convo_type + ))); + } + let dr_state: RatchetState = self.ratchet_storage.load(&record.local_convo_id)?; Ok(PrivateV1Convo::from_stored( diff --git a/core/conversations/src/errors.rs b/core/conversations/src/errors.rs index f47004c..664cdd3 100644 --- a/core/conversations/src/errors.rs +++ b/core/conversations/src/errors.rs @@ -22,6 +22,8 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), + #[error("unsupported conversation type: {0}")] + UnsupportedConvoType(String), #[error("storage error: {0}")] Storage(#[from] StorageError), } diff --git a/core/conversations/src/storage.rs b/core/conversations/src/storage.rs index eca4c6c..1b8c84d 100644 --- a/core/conversations/src/storage.rs +++ b/core/conversations/src/storage.rs @@ -180,6 +180,16 @@ impl ChatStorage { Ok(exists) } + /// Removes a conversation by its local ID. + #[allow(dead_code)] + pub fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> { + self.db.connection().execute( + "DELETE FROM conversations WHERE local_convo_id = ?1", + params![local_convo_id], + )?; + Ok(()) + } + /// Loads a single conversation record by its local ID. pub fn load_conversation( &self, @@ -223,15 +233,6 @@ impl ChatStorage { Ok(records) } - - /// Removes a conversation by its local ID. - pub fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> { - self.db.connection().execute( - "DELETE FROM conversations WHERE local_convo_id = ?1", - params![local_convo_id], - )?; - Ok(()) - } } #[cfg(test)]