diff --git a/Cargo.lock b/Cargo.lock index d8fbc00..a9422b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,9 +62,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.54" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -108,6 +108,18 @@ dependencies = [ "prost", ] +[[package]] +name = "chat-sqlite" +version = "0.1.0" +dependencies = [ + "crypto", + "hex", + "rusqlite", + "storage", + "tempfile", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" @@ -123,6 +135,7 @@ dependencies = [ name = "client" version = "0.1.0" dependencies = [ + "chat-sqlite", "libchat", ] @@ -232,6 +245,7 @@ version = "0.0.1" dependencies = [ "blake2", "chacha20poly1305", + "chat-sqlite", "hkdf", "rand", "rand_core", @@ -351,9 +365,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "foldhash" @@ -502,6 +516,7 @@ dependencies = [ "base64", "blake2", "chat-proto", + "chat-sqlite", "crypto", "double-ratchets", "hex", @@ -512,7 +527,6 @@ dependencies = [ "tempfile", "thiserror", "x25519-dalek", - "zeroize", ] [[package]] @@ -578,9 +592,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -930,7 +944,7 @@ dependencies = [ name = "storage" version = "0.1.0" dependencies = [ - "rusqlite", + "crypto", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index ca37bad..3702b53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "3" members = [ + "core/sqlite", "core/conversations", "core/crypto", "core/double-ratchets", diff --git a/core/conversations/Cargo.toml b/core/conversations/Cargo.toml index 4ea9408..c109bff 100644 --- a/core/conversations/Cargo.toml +++ b/core/conversations/Cargo.toml @@ -8,6 +8,7 @@ crate-type = ["rlib","staticlib","dylib"] [dependencies] base64 = "0.22" +sqlite = { package = "chat-sqlite", path = "../sqlite" } blake2.workspace = true chat-proto = { git = "https://github.com/logos-messaging/chat_proto" } crypto = { path = "../crypto" } @@ -19,7 +20,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" } -zeroize = { version = "1.8.2", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/core/conversations/src/api.rs b/core/conversations/src/api.rs index bd1e300..47f0a99 100644 --- a/core/conversations/src/api.rs +++ b/core/conversations/src/api.rs @@ -13,6 +13,8 @@ use safer_ffi::{ prelude::{c_slice, repr_c}, }; +use sqlite::{ChatStorage, StorageConfig}; + use crate::{ context::{Context, Introduction}, errors::ChatError, @@ -42,7 +44,7 @@ pub fn is_ok(error: i32) -> bool { /// Opaque wrapper for Context #[derive_ReprC] #[repr(opaque)] -pub struct ContextHandle(pub(crate) Context); +pub struct ContextHandle(pub(crate) Context); /// Creates a new libchat Ctx /// @@ -51,7 +53,9 @@ pub struct ContextHandle(pub(crate) Context); #[ffi_export] pub fn create_context(name: repr_c::String) -> repr_c::Box { // Deference name to to `str` and then borrow to &str - Box::new(ContextHandle(Context::new_with_name(&*name))).into() + let store = + ChatStorage::new(StorageConfig::InMemory).expect("in-memory storage should not fail"); + Box::new(ContextHandle(Context::new_with_name(&*name, store))).into() } /// Returns the friendly name of the contexts installation. @@ -130,7 +134,17 @@ pub fn create_new_private_convo( }; // Create conversation - let (convo_id, payloads) = ctx.0.create_private_convo(&intro, &content); + let (convo_id, payloads) = match ctx.0.create_private_convo(&intro, &content) { + Ok(v) => v, + Err(_) => { + *out = NewConvoResult { + error_code: ErrorCode::UnknownError as i32, + convo_id: "".into(), + payloads: Vec::new().into(), + }; + return; + } + }; // Convert payloads to FFI-compatible vector let ffi_payloads: Vec = payloads diff --git a/core/conversations/src/context.rs b/core/conversations/src/context.rs index bec37a5..36b9187 100644 --- a/core/conversations/src/context.rs +++ b/core/conversations/src/context.rs @@ -1,14 +1,15 @@ use std::rc::Rc; +use std::sync::Arc; -use storage::StorageConfig; +use crypto::Identity; +use double_ratchets::{RatchetState, restore_ratchet_state}; +use storage::{ChatStore, ConversationKind, ConversationMeta}; use crate::{ - conversation::{ConversationId, ConversationStore, Convo, Id}, + conversation::{Conversation, ConversationId, Convo, Id, PrivateV1Convo}, errors::ChatError, - identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, - storage::ChatStorage, types::{AddressedEnvelope, ContentData}, }; @@ -17,29 +18,26 @@ pub use crate::inbox::Introduction; // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. -pub struct Context { +pub struct Context { _identity: Rc, - store: ConversationStore, inbox: Inbox, - #[allow(dead_code)] // Will be used for conversation persistence - storage: ChatStorage, + store: T, } -impl Context { +impl Context { /// 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)?; + pub fn new_from_store(name: impl Into, mut store: T) -> Result { let name = name.into(); // Load or create identity - let identity = if let Some(identity) = storage.load_identity()? { + let identity = if let Some(identity) = store.load_identity()? { identity } else { let identity = Identity::new(&name); - storage.save_identity(&identity)?; + store.save_identity(&identity)?; identity }; @@ -48,17 +46,29 @@ impl Context { Ok(Self { _identity: identity, - store: ConversationStore::new(), inbox, - storage, + store, }) } /// 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 new_with_name(name: impl Into, mut chat_store: T) -> Self { + let name = name.into(); + let identity = Identity::new(&name); + chat_store + .save_identity(&identity) + .expect("in-memory storage should not fail"); + + let identity = Rc::new(identity); + let inbox = Inbox::new(Rc::clone(&identity)); + + Self { + _identity: identity, + inbox, + store: chat_store, + } } pub fn installation_name(&self) -> &str { @@ -69,7 +79,7 @@ impl Context { &mut self, remote_bundle: &Introduction, content: &[u8], - ) -> (ConversationIdOwned, Vec) { + ) -> Result<(ConversationIdOwned, Vec), ChatError> { let (convo, payloads) = self .inbox .invite_to_private_convo(remote_bundle, content) @@ -81,12 +91,16 @@ impl Context { .map(|p| p.into_envelope(remote_id.clone())) .collect(); - let convo_id = self.add_convo(Box::new(convo)); - (convo_id, payload_bytes) + let convo_id = self.persist_convo(&convo)?; + Ok((convo_id, payload_bytes)) } pub fn list_conversations(&self) -> Result, ChatError> { - Ok(self.store.conversation_ids()) + let records = self.store.load_conversations()?; + Ok(records + .into_iter() + .map(|r| Arc::from(r.local_convo_id.as_str())) + .collect()) } pub fn send_content( @@ -94,17 +108,20 @@ impl Context { convo_id: ConversationId, content: &[u8], ) -> Result, ChatError> { - // Lookup convo by id - let convo = self.get_convo_mut(convo_id)?; + let convo = self.load_convo(convo_id)?; - // Generate encrypted payloads - let payloads = convo.send_message(content)?; + match convo { + Conversation::Private(mut convo) => { + let payloads = convo.send_message(content)?; + let remote_id = convo.remote_id(); + convo.save_ratchet_state(&mut self.store)?; - // Attach conversation_ids to Envelopes - Ok(payloads - .into_iter() - .map(|p| p.into_envelope(convo.remote_id())) - .collect()) + Ok(payloads + .into_iter() + .map(|p| p.into_envelope(remote_id.clone())) + .collect()) + } + } } // Decode bytes and send to protocol for processing. @@ -116,7 +133,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.store.has_conversation(&c)? => self.dispatch_to_convo(&c, enc), _ => Ok(None), } } @@ -126,8 +143,20 @@ impl Context { &mut self, enc_payload: EncryptedPayload, ) -> Result, ChatError> { - let (convo, content) = self.inbox.handle_frame(enc_payload)?; - self.add_convo(convo); + // Look up the ephemeral key from storage + let key_hex = Inbox::extract_ephemeral_key_hex(&enc_payload)?; + let ephemeral_key = self + .store + .load_ephemeral_key(&key_hex)? + .ok_or(ChatError::UnknownEphemeralKey())?; + + let (convo, content) = self.inbox.handle_frame(&ephemeral_key, enc_payload)?; + + match convo { + Conversation::Private(convo) => self.persist_convo(&convo)?, + }; + + self.store.remove_ephemeral_key(&key_hex)?; Ok(content) } @@ -137,48 +166,74 @@ 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 convo = self.load_convo(convo_id)?; - convo.handle_frame(enc_payload) + match convo { + Conversation::Private(mut convo) => { + let result = convo.handle_frame(enc_payload)?; + convo.save_ratchet_state(&mut self.store)?; + Ok(result) + } + } } pub fn create_intro_bundle(&mut self) -> Result, ChatError> { - Ok(self.inbox.create_intro_bundle().into()) - } - - fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { - 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> { + let (intro, public_key_hex, private_key) = self.inbox.create_intro_bundle(); self.store - .get_mut(convo_id) - .ok_or_else(|| ChatError::NoConvo(convo_id.into())) + .save_ephemeral_key(&public_key_hex, &private_key)?; + Ok(intro.into()) + } + + /// Loads a conversation from DB by constructing it from metadata + ratchet state. + fn load_convo(&self, convo_id: ConversationId) -> Result { + let record = self + .store + .load_conversation(convo_id)? + .ok_or_else(|| ChatError::NoConvo(convo_id.into()))?; + + match record.kind { + ConversationKind::PrivateV1 => { + let dr_record = self.store.load_ratchet_state(&record.local_convo_id)?; + let skipped_keys = self.store.load_skipped_keys(&record.local_convo_id)?; + let dr_state: RatchetState = restore_ratchet_state(dr_record, skipped_keys); + + Ok(Conversation::Private(PrivateV1Convo::new( + record.local_convo_id, + record.remote_convo_id, + dr_state, + ))) + } + ConversationKind::Unknown(_) => Err(ChatError::BadBundleValue(format!( + "unsupported conversation type: {}", + record.kind.as_str() + ))), + } + } + + /// Persists a conversation's metadata and ratchet state to DB. + fn persist_convo(&mut self, convo: &PrivateV1Convo) -> Result { + let convo_info = ConversationMeta { + local_convo_id: convo.id().to_string(), + remote_convo_id: convo.remote_id(), + kind: convo.convo_type(), + }; + self.store.save_conversation(&convo_info)?; + convo.save_ratchet_state(&mut self.store)?; + Ok(Arc::from(convo.id())) } } #[cfg(test)] mod tests { + use sqlite::{ChatStorage, StorageConfig}; + use storage::{ConversationStore, IdentityStore}; + use tempfile::tempdir; + 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, - receiver: &mut Context, + sender: &mut Context, + receiver: &mut Context, convo_id: ConversationId, content: &[u8], ) { @@ -194,8 +249,8 @@ mod tests { #[test] fn ctx_integration() { - let mut saro = Context::new_with_name("saro"); - let mut raya = Context::new_with_name("raya"); + let mut saro = Context::new_with_name("saro", ChatStorage::in_memory()); + let mut raya = Context::new_with_name("raya", ChatStorage::in_memory()); // Raya creates intro bundle and sends to Saro let bundle = raya.create_intro_bundle().unwrap(); @@ -203,7 +258,7 @@ mod tests { // Saro initiates conversation with Raya let mut content = vec![10]; - let (saro_convo_id, payloads) = saro.create_private_convo(&intro, &content); + let (saro_convo_id, payloads) = saro.create_private_convo(&intro, &content).unwrap(); // Raya receives initial message let payload = payloads.first().unwrap(); @@ -228,28 +283,95 @@ mod tests { #[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 store1 = ChatStorage::new(StorageConfig::InMemory).unwrap(); + let ctx1 = Context::new_with_name("alice", store1); 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(); + // For persistence tests with file-based storage, we'd need a shared db. + // With in-memory, we just verify the identity was created. + assert_eq!(name1, "alice"); + assert!(!pubkey1.as_bytes().iter().all(|&b| b == 0)); + } - // Identity should be the same - assert_eq!(pubkey1, pubkey2, "public key should persist"); - assert_eq!(name1, name2, "name should persist"); + #[test] + fn open_persists_new_identity() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("chat.sqlite"); + let db_path = db_path.to_string_lossy().into_owned(); + + let store = ChatStorage::new(StorageConfig::File(db_path.clone())).unwrap(); + let ctx = Context::new_from_store("alice", store).unwrap(); + let pubkey = ctx._identity.public_key(); + drop(ctx); + + let store = ChatStorage::new(StorageConfig::File(db_path)).unwrap(); + let persisted = store.load_identity().unwrap().unwrap(); + + assert_eq!(persisted.get_name(), "alice"); + assert_eq!(persisted.public_key(), pubkey); + } + + #[test] + fn conversation_metadata_persistence() { + let mut alice = Context::new_with_name("alice", ChatStorage::in_memory()); + let mut bob = Context::new_with_name("bob", ChatStorage::in_memory()); + + 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").unwrap(); + + let payload = payloads.first().unwrap(); + let content = alice.handle_payload(&payload.data).unwrap().unwrap(); + assert!(content.is_new_convo); + + let convos = alice.store.load_conversations().unwrap(); + assert_eq!(convos.len(), 1); + assert_eq!(convos[0].kind.as_str(), "private_v1"); + } + + #[test] + fn conversation_full_flow() { + let mut alice = Context::new_with_name("alice", ChatStorage::in_memory()); + let mut bob = Context::new_with_name("bob", ChatStorage::in_memory()); + + 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").unwrap(); + + 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(); + + // Verify conversation list + let convo_ids = alice.list_conversations().unwrap(); + assert_eq!(convo_ids.len(), 1); + + // Continue exchanging messages + let payloads = bob.send_content(&bob_convo_id, b"more messages").unwrap(); + let payload = payloads.first().unwrap(); + let content = alice + .handle_payload(&payload.data) + .expect("should decrypt") + .expect("should have content"); + assert_eq!(content.data, b"more messages"); + + // Alice can also send back + let payloads = alice.send_content(&alice_convo_id, b"alice reply").unwrap(); + let payload = payloads.first().unwrap(); + let content = bob + .handle_payload(&payload.data) + .unwrap() + .expect("bob should receive"); + assert_eq!(content.data, b"alice reply"); } } diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index a148c5a..2c058dd 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -1,9 +1,13 @@ -use std::collections::HashMap; +mod privatev1; + +use crate::types::{AddressedEncryptedPayload, ContentData}; +use chat_proto::logoschat::encryption::EncryptedPayload; use std::fmt::Debug; use std::sync::Arc; +use storage::ConversationKind; pub use crate::errors::ChatError; -use crate::types::{AddressedEncryptedPayload, ContentData}; +pub use privatev1::PrivateV1Convo; pub type ConversationId<'a> = &'a str; pub type ConversationIdOwned = Arc; @@ -27,44 +31,11 @@ pub trait Convo: Id + Debug { ) -> Result, ChatError>; fn remote_id(&self) -> String; + + /// Returns the conversation type identifier for storage. + fn convo_type(&self) -> ConversationKind; } -pub struct ConversationStore { - conversations: HashMap, Box>, +pub enum Conversation { + Private(PrivateV1Convo), } - -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/core/conversations/src/conversation/privatev1.rs b/core/conversations/src/conversation/privatev1.rs index 0b8042e..f2f8a22 100644 --- a/core/conversations/src/conversation/privatev1.rs +++ b/core/conversations/src/conversation/privatev1.rs @@ -10,6 +10,7 @@ use crypto::{PrivateKey, PublicKey, SymmetricKey32}; use double_ratchets::{Header, InstallationKeyPair, RatchetState}; use prost::{Message, bytes::Bytes}; use std::fmt::Debug; +use storage::ConversationKind; use crate::{ conversation::{ChatError, ConversationId, Convo, Id}, @@ -18,6 +19,8 @@ use crate::{ types::{AddressedEncryptedPayload, ContentData}, utils::timestamp_millis, }; +use double_ratchets::{to_ratchet_record, to_skipped_key_records}; +use storage::RatchetStore; // Represents the potential participant roles in this Conversation enum Role { @@ -59,6 +62,15 @@ pub struct PrivateV1Convo { } impl PrivateV1Convo { + /// Reconstructs a PrivateV1Convo from persisted metadata and ratchet state. + pub fn new(local_convo_id: String, remote_convo_id: String, dr_state: RatchetState) -> Self { + Self { + local_convo_id, + remote_convo_id, + dr_state, + } + } + pub fn new_initiator(seed_key: SymmetricKey32, remote: PublicKey) -> Self { let base_convo_id = BaseConvoId::new(&seed_key); let local_convo_id = base_convo_id.id_for_participant(Role::Initiator); @@ -156,6 +168,13 @@ impl PrivateV1Convo { is_new_convo: false, }) } + + pub fn save_ratchet_state(&self, storage: &mut T) -> Result<(), ChatError> { + let record = to_ratchet_record(&self.dr_state); + let skipped_keys = to_skipped_key_records(&self.dr_state.skipped_keys()); + storage.save_ratchet_state(&self.local_convo_id, &record, &skipped_keys)?; + Ok(()) + } } impl Id for PrivateV1Convo { @@ -209,6 +228,10 @@ impl Convo for PrivateV1Convo { fn remote_id(&self) -> String { self.remote_convo_id.clone() } + + fn convo_type(&self) -> ConversationKind { + ConversationKind::PrivateV1 + } } impl Debug for PrivateV1Convo { 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/inbox/handler.rs b/core/conversations/src/inbox/handler.rs index 278ae16..b9e1126 100644 --- a/core/conversations/src/inbox/handler.rs +++ b/core/conversations/src/inbox/handler.rs @@ -3,18 +3,17 @@ 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}; use crate::context::Introduction; -use crate::conversation::{ChatError, ConversationId, Convo, Id, PrivateV1Convo}; +use crate::conversation::{ChatError, Conversation, ConversationId, Convo, Id, PrivateV1Convo}; use crate::crypto::{CopyBytes, PrivateKey, PublicKey}; -use crate::identity::Identity; use crate::inbox::handshake::InboxHandshake; use crate::proto; use crate::types::{AddressedEncryptedPayload, ContentData}; +use crypto::Identity; /// Compute the deterministic Delivery_address for an installation fn delivery_address_for_installation(_: PublicKey) -> String { @@ -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,18 +41,19 @@ impl Inbox { Self { ident, local_convo_id, - ephemeral_keys: HashMap::::new(), } } - pub fn create_intro_bundle(&mut self) -> Introduction { + /// 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(); - self.ephemeral_keys - .insert(hex::encode(ephemeral_key.as_bytes()), ephemeral); + let public_key_hex = hex::encode(ephemeral_key.as_bytes()); - 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,20 +109,19 @@ impl Inbox { Ok((convo, payloads)) } + /// 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), ChatError> { + ) -> Result<(Conversation, 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)?; @@ -148,11 +142,27 @@ impl Inbox { None => return Err(ChatError::Protocol("expected contentData".into())), }; - Ok((Box::new(convo), Some(content))) + Ok((Conversation::Private(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(), @@ -214,12 +224,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)) @@ -235,24 +239,34 @@ impl Id for Inbox { #[cfg(test)] mod tests { use super::*; + use sqlite::{ChatStorage, StorageConfig}; + use storage::EphemeralKeyStore; #[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 = 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/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index de0c023..20a4c0b 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -3,16 +3,15 @@ mod context; mod conversation; mod crypto; mod errors; -mod identity; mod inbox; mod proto; -mod storage; mod types; mod utils; pub use api::*; pub use context::{Context, Introduction}; pub use errors::ChatError; +pub use sqlite::ChatStorage; #[cfg(test)] mod tests { diff --git a/core/conversations/src/storage.rs b/core/conversations/src/storage.rs deleted file mode 100644 index c0130a2..0000000 --- a/core/conversations/src/storage.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Chat-specific storage implementation. - -mod migrations; -mod types; - -use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; -use zeroize::Zeroize; - -use crate::{identity::Identity, storage::types::IdentityRecord}; - -/// 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_migrations(db) - } - - /// Applies all migrations and returns the storage instance. - fn run_migrations(mut db: SqliteDb) -> Result { - migrations::apply_migrations(db.connection_mut())?; - Ok(Self { db }) - } - - // ==================== 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> { - 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(), 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 - .connection() - .prepare("SELECT name, secret_key FROM identity WHERE id = 1")?; - - let result = stmt.query_row([], |row| { - let name: String = row.get(0)?; - let secret_key: Vec = row.get(1)?; - Ok((name, secret_key)) - }); - - match result { - 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, - }; - Ok(Some(Identity::from(record))) - } - Err(RusqliteError::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } -} - -#[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("default"); - let pubkey = identity.public_key(); - storage.save_identity(&identity).unwrap(); - - // Load identity - let loaded = storage.load_identity().unwrap().unwrap(); - assert_eq!(loaded.public_key(), pubkey); - } -} diff --git a/core/conversations/src/storage/migrations/001_initial_schema.sql b/core/conversations/src/storage/migrations/001_initial_schema.sql deleted file mode 100644 index 5a97bfe..0000000 --- a/core/conversations/src/storage/migrations/001_initial_schema.sql +++ /dev/null @@ -1,9 +0,0 @@ --- 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 -); diff --git a/core/conversations/src/identity.rs b/core/crypto/src/identity.rs similarity index 95% rename from core/conversations/src/identity.rs rename to core/crypto/src/identity.rs index 8ca27be..7b29b2f 100644 --- a/core/conversations/src/identity.rs +++ b/core/crypto/src/identity.rs @@ -1,7 +1,8 @@ use std::fmt; -use crate::crypto::{PrivateKey, PublicKey}; +use crate::{PrivateKey, PublicKey}; +#[derive(Clone)] pub struct Identity { name: String, secret: PrivateKey, diff --git a/core/crypto/src/lib.rs b/core/crypto/src/lib.rs index 4b095ba..fff9cd2 100644 --- a/core/crypto/src/lib.rs +++ b/core/crypto/src/lib.rs @@ -1,7 +1,9 @@ +mod identity; mod keys; mod x3dh; mod xeddsa_sign; +pub use identity::Identity; pub use keys::{PrivateKey, PublicKey, SymmetricKey32}; pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake}; pub use xeddsa_sign::{Ed25519Signature, SignatureError, xeddsa_sign, xeddsa_verify}; diff --git a/core/double-ratchets/Cargo.toml b/core/double-ratchets/Cargo.toml index 6d1038c..af51920 100644 --- a/core/double-ratchets/Cargo.toml +++ b/core/double-ratchets/Cargo.toml @@ -27,4 +27,5 @@ serde = "1.0" headers = ["safer-ffi/headers"] [dev-dependencies] -tempfile = "3" \ No newline at end of file +sqlite = { package = "chat-sqlite", path = "../sqlite" } +tempfile = "3" diff --git a/core/double-ratchets/examples/out_of_order_demo.rs b/core/double-ratchets/examples/out_of_order_demo.rs index 246fa0f..b01de57 100644 --- a/core/double-ratchets/examples/out_of_order_demo.rs +++ b/core/double-ratchets/examples/out_of_order_demo.rs @@ -2,7 +2,8 @@ //! //! Run with: cargo run --example out_of_order_demo -p double-ratchets -use double_ratchets::{InstallationKeyPair, RatchetSession, RatchetStorage}; +use double_ratchets::{InstallationKeyPair, RatchetSession}; +use sqlite::{ChatStorage, StorageConfig}; use tempfile::NamedTempFile; fn main() { @@ -18,19 +19,18 @@ fn main() { let bob_public = *bob_keypair.public(); let conv_id = "out_of_order_conv"; - let encryption_key = "super-secret-key-123!"; // Collect messages for out-of-order delivery let mut messages: Vec<(Vec, double_ratchets::Header)> = Vec::new(); // Phase 1: Alice sends 5 messages, Bob receives 1, 3, 5 (skipping 2, 4) { - let mut alice_storage = RatchetStorage::new(alice_db_path, encryption_key) - .expect("Failed to create Alice storage"); + let mut alice_storage = + ChatStorage::new(StorageConfig::File(alice_db_path.to_string())).unwrap(); let mut bob_storage = - RatchetStorage::new(bob_db_path, encryption_key).expect("Failed to create Bob storage"); + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); - let mut alice_session: RatchetSession = RatchetSession::create_sender_session( + let mut alice_session: RatchetSession = RatchetSession::create_sender_session( &mut alice_storage, conv_id, shared_secret, @@ -38,7 +38,7 @@ fn main() { ) .unwrap(); - let mut bob_session: RatchetSession = RatchetSession::create_receiver_session( + let mut bob_session: RatchetSession = RatchetSession::create_receiver_session( &mut bob_storage, conv_id, shared_secret, @@ -72,9 +72,10 @@ fn main() { println!("\n Simulating app restart..."); { let mut bob_storage = - RatchetStorage::new(bob_db_path, encryption_key).expect("Failed to reopen Bob storage"); + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); - let bob_session: RatchetSession = RatchetSession::open(&mut bob_storage, conv_id).unwrap(); + let bob_session: RatchetSession = + RatchetSession::open(&mut bob_storage, conv_id).unwrap(); println!( " After restart, Bob's skipped_keys: {}", bob_session.state().skipped_keys.len() @@ -86,9 +87,9 @@ fn main() { let (ct4, header4) = messages[3].clone(); // Save for replay test { let mut bob_storage = - RatchetStorage::new(bob_db_path, encryption_key).expect("Failed to open Bob storage"); + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); - let mut bob_session: RatchetSession = + let mut bob_session: RatchetSession = RatchetSession::open(&mut bob_storage, conv_id).unwrap(); let (ct, header) = &messages[1]; @@ -103,9 +104,9 @@ fn main() { println!("\nBob receives delayed message 4..."); { let mut bob_storage = - RatchetStorage::new(bob_db_path, encryption_key).expect("Failed to open Bob storage"); + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); - let mut bob_session: RatchetSession = + let mut bob_session: RatchetSession = RatchetSession::open(&mut bob_storage, conv_id).unwrap(); let pt = bob_session.decrypt_message(&ct4, header4.clone()).unwrap(); @@ -121,9 +122,9 @@ fn main() { println!("Trying to decrypt message 4 again (should fail)..."); { let mut bob_storage = - RatchetStorage::new(bob_db_path, encryption_key).expect("Failed to open Bob storage"); + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); - let mut bob_session: RatchetSession = + let mut bob_session: RatchetSession = RatchetSession::open(&mut bob_storage, conv_id).unwrap(); match bob_session.decrypt_message(&ct4, header4) { diff --git a/core/double-ratchets/examples/storage_demo.rs b/core/double-ratchets/examples/storage_demo.rs index 771c598..258d655 100644 --- a/core/double-ratchets/examples/storage_demo.rs +++ b/core/double-ratchets/examples/storage_demo.rs @@ -2,7 +2,8 @@ //! //! Run with: cargo run --example storage_demo -p double-ratchets -use double_ratchets::{InstallationKeyPair, RatchetSession, RatchetStorage}; +use double_ratchets::{InstallationKeyPair, RatchetSession}; +use sqlite::{ChatStorage, StorageConfig}; use tempfile::NamedTempFile; fn main() { @@ -13,28 +14,23 @@ fn main() { let bob_db_file = NamedTempFile::new().unwrap(); let bob_db_path = bob_db_file.path().to_str().unwrap(); - let encryption_key = "super-secret-key-123!"; - - // Initial conversation with encryption + // Initial conversation { - let mut alice_storage = RatchetStorage::new(alice_db_path, encryption_key) - .expect("Failed to create alice encrypted storage"); - let mut bob_storage = RatchetStorage::new(bob_db_path, encryption_key) - .expect("Failed to create bob encrypted storage"); - println!( - " Encrypted database created at: {}, {}", - alice_db_path, bob_db_path - ); + let mut alice_storage = + ChatStorage::new(StorageConfig::File(alice_db_path.to_string())).unwrap(); + let mut bob_storage = + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); + println!(" Database created at: {}, {}", alice_db_path, bob_db_path); run_conversation(&mut alice_storage, &mut bob_storage); } - // Restart with correct key - println!("\n Simulating restart with encryption key..."); + // Restart + println!("\n Simulating restart..."); { - let mut alice_storage = RatchetStorage::new(alice_db_path, encryption_key) - .expect("Failed to create alice encrypted storage"); - let mut bob_storage = RatchetStorage::new(bob_db_path, encryption_key) - .expect("Failed to create bob encrypted storage"); + let mut alice_storage = + ChatStorage::new(StorageConfig::File(alice_db_path.to_string())).unwrap(); + let mut bob_storage = + ChatStorage::new(StorageConfig::File(bob_db_path.to_string())).unwrap(); continue_after_restart(&mut alice_storage, &mut bob_storage); } @@ -44,14 +40,14 @@ fn main() { /// Simulates a conversation between Alice and Bob. /// Each party saves/loads state from storage for each operation. -fn run_conversation(alice_storage: &mut RatchetStorage, bob_storage: &mut RatchetStorage) { +fn run_conversation(alice_storage: &mut ChatStorage, bob_storage: &mut ChatStorage) { // === Setup: Simulate X3DH key exchange === let shared_secret = [0x42u8; 32]; // In reality, this comes from X3DH let bob_keypair = InstallationKeyPair::generate(); let conv_id = "conv1"; - let mut alice_session: RatchetSession = RatchetSession::create_sender_session( + let mut alice_session: RatchetSession = RatchetSession::create_sender_session( alice_storage, conv_id, shared_secret, @@ -59,7 +55,7 @@ fn run_conversation(alice_storage: &mut RatchetStorage, bob_storage: &mut Ratche ) .unwrap(); - let mut bob_session: RatchetSession = + let mut bob_session: RatchetSession = RatchetSession::create_receiver_session(bob_storage, conv_id, shared_secret, bob_keypair) .unwrap(); @@ -115,12 +111,14 @@ fn run_conversation(alice_storage: &mut RatchetStorage, bob_storage: &mut Ratche ); } -fn continue_after_restart(alice_storage: &mut RatchetStorage, bob_storage: &mut RatchetStorage) { +fn continue_after_restart(alice_storage: &mut ChatStorage, bob_storage: &mut ChatStorage) { // Load persisted states let conv_id = "conv1"; - let mut alice_session: RatchetSession = RatchetSession::open(alice_storage, conv_id).unwrap(); - let mut bob_session: RatchetSession = RatchetSession::open(bob_storage, conv_id).unwrap(); + let mut alice_session: RatchetSession = + RatchetSession::open(alice_storage, conv_id).unwrap(); + let mut bob_session: RatchetSession = + RatchetSession::open(bob_storage, conv_id).unwrap(); println!(" Sessions restored for Alice and Bob",); // Continue conversation diff --git a/core/double-ratchets/src/lib.rs b/core/double-ratchets/src/lib.rs index c5abe43..b8778fc 100644 --- a/core/double-ratchets/src/lib.rs +++ b/core/double-ratchets/src/lib.rs @@ -10,5 +10,6 @@ pub mod types; pub use keypair::InstallationKeyPair; pub use state::{Header, RatchetState, SkippedKey}; -pub use storage::StorageConfig; -pub use storage::{RatchetSession, RatchetStorage, SessionError}; +pub use storage::{ + RatchetSession, SessionError, restore_ratchet_state, to_ratchet_record, to_skipped_key_records, +}; diff --git a/core/double-ratchets/src/storage/db.rs b/core/double-ratchets/src/storage/db.rs deleted file mode 100644 index 43b3f4b..0000000 --- a/core/double-ratchets/src/storage/db.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Ratchet-specific storage implementation. - -use std::collections::HashSet; - -use storage::{SqliteDb, StorageError, params}; - -use super::types::RatchetStateRecord; -use crate::{ - hkdf::HkdfInfo, - state::{RatchetState, SkippedKey}, -}; - -/// Schema for ratchet state tables. -const RATCHET_SCHEMA: &str = " - CREATE TABLE IF NOT EXISTS ratchet_state ( - conversation_id TEXT PRIMARY KEY, - root_key BLOB NOT NULL, - sending_chain BLOB, - receiving_chain BLOB, - dh_self_secret BLOB NOT NULL, - dh_remote BLOB, - msg_send INTEGER NOT NULL, - msg_recv INTEGER NOT NULL, - prev_chain_len INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS skipped_keys ( - conversation_id TEXT NOT NULL, - public_key BLOB NOT NULL, - msg_num INTEGER NOT NULL, - message_key BLOB NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), - PRIMARY KEY (conversation_id, public_key, msg_num), - FOREIGN KEY (conversation_id) REFERENCES ratchet_state(conversation_id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_skipped_keys_conversation - ON skipped_keys(conversation_id); -"; - -/// Ratchet-specific storage operations. -/// -/// This struct wraps a `SqliteDb` and provides domain-specific -/// storage operations for ratchet state. -pub struct RatchetStorage { - db: SqliteDb, -} - -impl RatchetStorage { - /// Opens an existing encrypted database file. - pub fn new(path: &str, key: &str) -> Result { - let db = SqliteDb::sqlcipher(path.to_string(), key.to_string())?; - Self::run_migration(db) - } - - /// Creates an in-memory storage (useful for testing). - pub fn in_memory() -> Result { - let db = SqliteDb::in_memory()?; - Self::run_migration(db) - } - - /// Creates a new ratchet storage with the given database. - fn run_migration(db: SqliteDb) -> Result { - // Initialize schema - db.connection().execute_batch(RATCHET_SCHEMA)?; - Ok(Self { db }) - } - - /// Saves the ratchet state for a conversation. - pub fn save( - &mut self, - conversation_id: &str, - state: &RatchetState, - ) -> Result<(), StorageError> { - let tx = self.db.transaction()?; - - let data = RatchetStateRecord::from(state); - let skipped_keys: Vec = state.skipped_keys(); - - // Upsert main state - tx.execute( - " - INSERT INTO ratchet_state ( - conversation_id, root_key, sending_chain, receiving_chain, - dh_self_secret, dh_remote, msg_send, msg_recv, prev_chain_len - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(conversation_id) DO UPDATE SET - root_key = excluded.root_key, - sending_chain = excluded.sending_chain, - receiving_chain = excluded.receiving_chain, - dh_self_secret = excluded.dh_self_secret, - dh_remote = excluded.dh_remote, - msg_send = excluded.msg_send, - msg_recv = excluded.msg_recv, - prev_chain_len = excluded.prev_chain_len - ", - params![ - conversation_id, - data.root_key.as_slice(), - data.sending_chain.as_ref().map(|c| c.as_slice()), - data.receiving_chain.as_ref().map(|c| c.as_slice()), - data.dh_self_secret.as_slice(), - data.dh_remote.as_ref().map(|c| c.as_slice()), - data.msg_send, - data.msg_recv, - data.prev_chain_len, - ], - )?; - - // Sync skipped keys - sync_skipped_keys(&tx, conversation_id, skipped_keys)?; - - tx.commit()?; - Ok(()) - } - - /// Loads the ratchet state for a conversation. - pub fn load( - &self, - conversation_id: &str, - ) -> Result, StorageError> { - let data = self.load_state_data(conversation_id)?; - let skipped_keys = self.load_skipped_keys(conversation_id)?; - Ok(data.into_ratchet_state(skipped_keys)) - } - - fn load_state_data(&self, conversation_id: &str) -> Result { - let conn = self.db.connection(); - let mut stmt = conn.prepare( - " - SELECT root_key, sending_chain, receiving_chain, dh_self_secret, - dh_remote, msg_send, msg_recv, prev_chain_len - FROM ratchet_state - WHERE conversation_id = ?1 - ", - )?; - - stmt.query_row(params![conversation_id], |row| { - Ok(RatchetStateRecord { - root_key: blob_to_array(row.get::<_, Vec>(0)?), - sending_chain: row.get::<_, Option>>(1)?.map(blob_to_array), - receiving_chain: row.get::<_, Option>>(2)?.map(blob_to_array), - dh_self_secret: blob_to_array(row.get::<_, Vec>(3)?), - dh_remote: row.get::<_, Option>>(4)?.map(blob_to_array), - msg_send: row.get(5)?, - msg_recv: row.get(6)?, - prev_chain_len: row.get(7)?, - }) - }) - .map_err(|e| match e { - storage::RusqliteError::QueryReturnedNoRows => { - StorageError::NotFound(conversation_id.to_string()) - } - e => StorageError::Database(e.to_string()), - }) - } - - fn load_skipped_keys(&self, conversation_id: &str) -> Result, StorageError> { - let conn = self.db.connection(); - let mut stmt = conn.prepare( - " - SELECT public_key, msg_num, message_key - FROM skipped_keys - WHERE conversation_id = ?1 - ", - )?; - - let rows = stmt.query_map(params![conversation_id], |row| { - Ok(SkippedKey { - public_key: blob_to_array(row.get::<_, Vec>(0)?), - msg_num: row.get(1)?, - message_key: blob_to_array(row.get::<_, Vec>(2)?), - }) - })?; - - rows.collect::, _>>() - .map_err(|e| StorageError::Database(e.to_string())) - } - - /// Checks if a conversation exists. - pub fn exists(&self, conversation_id: &str) -> Result { - let conn = self.db.connection(); - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM ratchet_state WHERE conversation_id = ?1", - params![conversation_id], - |row| row.get(0), - )?; - Ok(count > 0) - } - - /// Deletes a conversation and its skipped keys. - pub fn delete(&mut self, conversation_id: &str) -> Result<(), StorageError> { - let tx = self.db.transaction()?; - tx.execute( - "DELETE FROM skipped_keys WHERE conversation_id = ?1", - params![conversation_id], - )?; - tx.execute( - "DELETE FROM ratchet_state WHERE conversation_id = ?1", - params![conversation_id], - )?; - tx.commit()?; - Ok(()) - } - - /// Cleans up old skipped keys older than the given age in seconds. - pub fn cleanup_old_skipped_keys(&mut self, max_age_secs: i64) -> Result { - let conn = self.db.connection(); - let deleted = conn.execute( - "DELETE FROM skipped_keys WHERE created_at < strftime('%s', 'now') - ?1", - params![max_age_secs], - )?; - Ok(deleted) - } -} - -/// Syncs skipped keys efficiently by computing diff and only inserting/deleting changes. -fn sync_skipped_keys( - tx: &storage::Transaction, - conversation_id: &str, - current_keys: Vec, -) -> Result<(), StorageError> { - // Get existing keys from DB (just the identifiers) - let mut stmt = - tx.prepare("SELECT public_key, msg_num FROM skipped_keys WHERE conversation_id = ?1")?; - let existing: HashSet<([u8; 32], u32)> = stmt - .query_map(params![conversation_id], |row| { - Ok(( - blob_to_array(row.get::<_, Vec>(0)?), - row.get::<_, u32>(1)?, - )) - })? - .filter_map(|r| r.ok()) - .collect(); - - // Build set of current keys - let current_set: HashSet<([u8; 32], u32)> = current_keys - .iter() - .map(|sk| (sk.public_key, sk.msg_num)) - .collect(); - - // Delete keys that were removed (used for decryption) - for (pk, msg_num) in existing.difference(¤t_set) { - tx.execute( - "DELETE FROM skipped_keys WHERE conversation_id = ?1 AND public_key = ?2 AND msg_num = ?3", - params![conversation_id, pk.as_slice(), msg_num], - )?; - } - - // Insert new keys - for sk in ¤t_keys { - let key = (sk.public_key, sk.msg_num); - if !existing.contains(&key) { - tx.execute( - "INSERT INTO skipped_keys (conversation_id, public_key, msg_num, message_key) - VALUES (?1, ?2, ?3, ?4)", - params![ - conversation_id, - sk.public_key.as_slice(), - sk.msg_num, - sk.message_key.as_slice(), - ], - )?; - } - } - - Ok(()) -} - -fn blob_to_array(blob: Vec) -> [u8; N] { - blob.try_into() - .unwrap_or_else(|v: Vec| panic!("Expected {} bytes, got {}", N, v.len())) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{keypair::InstallationKeyPair, state::RatchetState, types::SharedSecret}; - - fn create_test_state() -> (RatchetState, SharedSecret) { - let shared_secret = [0x42u8; 32]; - let bob_keypair = InstallationKeyPair::generate(); - let state = RatchetState::init_sender(shared_secret, *bob_keypair.public()); - (state, shared_secret) - } - - #[test] - fn test_save_and_load() { - let mut storage = RatchetStorage::in_memory().unwrap(); - let (state, _) = create_test_state(); - - storage.save("conv1", &state).unwrap(); - let loaded: RatchetState = storage.load("conv1").unwrap(); - - assert_eq!(state.root_key, loaded.root_key); - assert_eq!(state.msg_send, loaded.msg_send); - } - - #[test] - fn test_exists() { - let mut storage = RatchetStorage::in_memory().unwrap(); - let (state, _) = create_test_state(); - - assert!(!storage.exists("conv1").unwrap()); - storage.save("conv1", &state).unwrap(); - assert!(storage.exists("conv1").unwrap()); - } - - #[test] - fn test_delete() { - let mut storage = RatchetStorage::in_memory().unwrap(); - let (state, _) = create_test_state(); - - storage.save("conv1", &state).unwrap(); - assert!(storage.exists("conv1").unwrap()); - - storage.delete("conv1").unwrap(); - assert!(!storage.exists("conv1").unwrap()); - } -} diff --git a/core/double-ratchets/src/storage/mod.rs b/core/double-ratchets/src/storage/mod.rs index 354cae6..4a45618 100644 --- a/core/double-ratchets/src/storage/mod.rs +++ b/core/double-ratchets/src/storage/mod.rs @@ -1,15 +1,13 @@ //! Storage module for persisting ratchet state. //! -//! This module provides storage implementations for the double ratchet state, -//! built on top of the shared `storage` crate. +//! This module provides session management for the double ratchet state, +//! built on top of the `RatchetStore` trait from the `storage` crate. -mod db; mod errors; mod session; mod types; -pub use db::RatchetStorage; pub use errors::SessionError; pub use session::RatchetSession; -pub use storage::{SqliteDb, StorageConfig, StorageError}; -pub use types::RatchetStateRecord; +pub use storage::{RatchetStateRecord, RatchetStore, SkippedKeyRecord, StorageError}; +pub use types::{restore_ratchet_state, to_ratchet_record, to_skipped_key_records}; diff --git a/core/double-ratchets/src/storage/session.rs b/core/double-ratchets/src/storage/session.rs index ea3cdfc..069ba4d 100644 --- a/core/double-ratchets/src/storage/session.rs +++ b/core/double-ratchets/src/storage/session.rs @@ -1,7 +1,9 @@ //! Session wrapper for automatic state persistence. +use storage::RatchetStore; use x25519_dalek::PublicKey; +use super::types::{restore_ratchet_state, to_ratchet_record, to_skipped_key_records}; use crate::{ InstallationKeyPair, SessionError, hkdf::{DefaultDomain, HkdfInfo}, @@ -9,24 +11,24 @@ use crate::{ types::SharedSecret, }; -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, +pub struct RatchetSession<'a, S: RatchetStore, D: HkdfInfo + Clone = DefaultDomain> { + storage: &'a mut S, conversation_id: String, state: RatchetState, } -impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { +impl<'a, S: RatchetStore, D: HkdfInfo + Clone> RatchetSession<'a, S, D> { /// Opens an existing session from storage. pub fn open( - storage: &'a mut RatchetStorage, + storage: &'a mut S, conversation_id: impl Into, ) -> Result { let conversation_id = conversation_id.into(); - let state = storage.load(&conversation_id)?; + let record = storage.load_ratchet_state(&conversation_id)?; + let skipped_keys = storage.load_skipped_keys(&conversation_id)?; + let state = restore_ratchet_state(record, skipped_keys); Ok(Self { storage, conversation_id, @@ -36,12 +38,12 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Creates a new session and persists the initial state. pub fn create( - storage: &'a mut RatchetStorage, + storage: &'a mut S, conversation_id: impl Into, state: RatchetState, ) -> Result { let conversation_id = conversation_id.into(); - storage.save(&conversation_id, &state)?; + save_state(storage, &conversation_id, &state)?; Ok(Self { storage, conversation_id, @@ -51,12 +53,12 @@ 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: &'a mut S, conversation_id: &str, shared_secret: SharedSecret, remote_pub: PublicKey, ) -> Result { - if storage.exists(conversation_id)? { + if storage.has_ratchet_state(conversation_id)? { return Err(SessionError::ConvAlreadyExists(conversation_id.to_string())); } let state = RatchetState::::init_sender(shared_secret, remote_pub); @@ -65,12 +67,12 @@ 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: &'a mut S, conversation_id: &str, shared_secret: SharedSecret, dh_self: InstallationKeyPair, ) -> Result { - if storage.exists(conversation_id)? { + if storage.has_ratchet_state(conversation_id)? { return Err(SessionError::ConvAlreadyExists(conversation_id.to_string())); } @@ -88,7 +90,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { let result = self.state.encrypt_message(plaintext); // Try to persist - if let Err(e) = self.storage.save(&self.conversation_id, &self.state) { + if let Err(e) = save_state(self.storage, &self.conversation_id, &self.state) { // Rollback self.state = state_backup; return Err(e.into()); @@ -118,7 +120,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { }; // Try to persist - if let Err(e) = self.storage.save(&self.conversation_id, &self.state) { + if let Err(e) = save_state(self.storage, &self.conversation_id, &self.state) { // Rollback self.state = state_backup; return Err(e.into()); @@ -139,9 +141,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Manually saves the current state. pub fn save(&mut self) -> Result<(), SessionError> { - self.storage - .save(&self.conversation_id, &self.state) - .map_err(|error| error.into()) + save_state(self.storage, &self.conversation_id, &self.state).map_err(|error| error.into()) } pub fn msg_send(&self) -> u32 { @@ -153,13 +153,25 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { } } +/// Helper to save ratchet state through the RatchetStore trait. +fn save_state( + storage: &mut S, + conversation_id: &str, + state: &RatchetState, +) -> Result<(), storage::StorageError> { + let record = to_ratchet_record(state); + let skipped_keys = to_skipped_key_records(&state.skipped_keys()); + storage.save_ratchet_state(conversation_id, &record, &skipped_keys) +} + #[cfg(test)] mod tests { use super::*; use crate::hkdf::DefaultDomain; + use sqlite::ChatStorage; - fn create_test_storage() -> RatchetStorage { - RatchetStorage::in_memory().unwrap() + fn create_test_storage() -> ChatStorage { + ChatStorage::in_memory() } #[test] @@ -179,7 +191,7 @@ mod tests { // Open existing session { - let session: RatchetSession = + let session: RatchetSession = RatchetSession::open(&mut storage, "conv1").unwrap(); assert_eq!(session.state().msg_send, 0); } @@ -203,7 +215,7 @@ mod tests { // Reopen - state should be persisted { - let session: RatchetSession = + let session: RatchetSession = RatchetSession::open(&mut storage, "conv1").unwrap(); assert_eq!(session.state().msg_send, 1); } @@ -235,14 +247,14 @@ mod tests { // Bob replies let (ct2, header2) = { - let mut session: RatchetSession = + let mut session: RatchetSession = RatchetSession::open(&mut storage, "bob").unwrap(); session.encrypt_message(b"Hi Alice").unwrap() }; // Alice receives let plaintext2 = { - let mut session: RatchetSession = + let mut session: RatchetSession = RatchetSession::open(&mut storage, "alice").unwrap(); session.decrypt_message(&ct2, header2).unwrap() }; @@ -259,26 +271,27 @@ mod tests { // First call creates { - let session: RatchetSession = RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ) - .unwrap(); + 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 { - let mut session: RatchetSession = + let mut session: RatchetSession = RatchetSession::open(&mut storage, "conv1").unwrap(); session.encrypt_message(b"test").unwrap(); } // Verify persistence { - let session: RatchetSession = + let session: RatchetSession = RatchetSession::open(&mut storage, "conv1").unwrap(); assert_eq!(session.state().msg_send, 1); } @@ -294,18 +307,19 @@ mod tests { // 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( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ) + .unwrap(); } // Second creation should fail with ConversationAlreadyExists { - let result: Result, _> = + let result: Result, _> = RatchetSession::create_sender_session( &mut storage, "conv1", @@ -326,19 +340,20 @@ mod tests { // 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( + &mut storage, + "conv1", + shared_secret, + bob_keypair, + ) + .unwrap(); } // Second creation should fail with ConversationAlreadyExists { let another_keypair = InstallationKeyPair::generate(); - let result: Result, _> = + let result: Result, _> = RatchetSession::create_receiver_session( &mut storage, "conv1", diff --git a/core/double-ratchets/src/storage/types.rs b/core/double-ratchets/src/storage/types.rs index 485e67a..eb7fed6 100644 --- a/core/double-ratchets/src/storage/types.rs +++ b/core/double-ratchets/src/storage/types.rs @@ -1,65 +1,65 @@ -//! Storage types for ratchet state. +//! Storage type conversions between ratchet state and storage records. + +use storage::{RatchetStateRecord, SkippedKeyRecord}; use crate::{ hkdf::HkdfInfo, state::{RatchetState, SkippedKey}, - types::MessageKey, }; -use x25519_dalek::PublicKey; -/// Raw state data for storage (without generic parameter). -#[derive(Debug, Clone)] -pub struct RatchetStateRecord { - pub root_key: [u8; 32], - pub sending_chain: Option<[u8; 32]>, - pub receiving_chain: Option<[u8; 32]>, - pub dh_self_secret: [u8; 32], - pub dh_remote: Option<[u8; 32]>, - pub msg_send: u32, - pub msg_recv: u32, - pub prev_chain_len: u32, -} - -impl From<&RatchetState> for RatchetStateRecord { - fn from(state: &RatchetState) -> Self { - Self { - root_key: state.root_key, - sending_chain: state.sending_chain, - receiving_chain: state.receiving_chain, - dh_self_secret: *state.dh_self.secret_bytes(), - dh_remote: state.dh_remote.map(|pk| pk.to_bytes()), - msg_send: state.msg_send, - msg_recv: state.msg_recv, - prev_chain_len: state.prev_chain_len, - } +/// Converts a `RatchetState` into a `RatchetStateRecord` for storage. +pub fn to_ratchet_record(state: &RatchetState) -> RatchetStateRecord { + RatchetStateRecord { + root_key: state.root_key, + sending_chain: state.sending_chain, + receiving_chain: state.receiving_chain, + dh_self_secret: *state.dh_self.secret_bytes(), + dh_remote: state.dh_remote.map(|pk| pk.to_bytes()), + msg_send: state.msg_send, + msg_recv: state.msg_recv, + prev_chain_len: state.prev_chain_len, } } -impl RatchetStateRecord { - pub fn into_ratchet_state(self, skipped_keys: Vec) -> RatchetState { - use crate::keypair::InstallationKeyPair; - use std::collections::HashMap; - use std::marker::PhantomData; +/// Converts a `RatchetStateRecord` and skipped keys back into a `RatchetState`. +pub fn restore_ratchet_state( + record: RatchetStateRecord, + skipped_keys: Vec, +) -> RatchetState { + use crate::keypair::InstallationKeyPair; + use std::collections::HashMap; + use std::marker::PhantomData; + use x25519_dalek::PublicKey; - let dh_self = InstallationKeyPair::from_secret_bytes(self.dh_self_secret); - let dh_remote = self.dh_remote.map(PublicKey::from); + let dh_self = InstallationKeyPair::from_secret_bytes(record.dh_self_secret); + let dh_remote = record.dh_remote.map(PublicKey::from); - let skipped: HashMap<(PublicKey, u32), MessageKey> = skipped_keys - .into_iter() - .map(|sk| ((PublicKey::from(sk.public_key), sk.msg_num), sk.message_key)) - .collect(); + let skipped: HashMap<(PublicKey, u32), crate::types::MessageKey> = skipped_keys + .into_iter() + .map(|sk| ((PublicKey::from(sk.public_key), sk.msg_num), sk.message_key)) + .collect(); - RatchetState { - root_key: self.root_key, - sending_chain: self.sending_chain, - receiving_chain: self.receiving_chain, - dh_self, - dh_remote, - msg_send: self.msg_send, - msg_recv: self.msg_recv, - prev_chain_len: self.prev_chain_len, - skipped_keys: skipped, - _domain: PhantomData, - } + RatchetState { + root_key: record.root_key, + sending_chain: record.sending_chain, + receiving_chain: record.receiving_chain, + dh_self, + dh_remote, + msg_send: record.msg_send, + msg_recv: record.msg_recv, + prev_chain_len: record.prev_chain_len, + skipped_keys: skipped, + _domain: PhantomData, } } + +/// Converts skipped keys from ratchet state format to storage record format. +pub fn to_skipped_key_records(keys: &[SkippedKey]) -> Vec { + keys.iter() + .map(|sk| SkippedKeyRecord { + public_key: sk.public_key, + msg_num: sk.msg_num, + message_key: sk.message_key, + }) + .collect() +} diff --git a/core/sqlite/Cargo.toml b/core/sqlite/Cargo.toml new file mode 100644 index 0000000..bd0e3ee --- /dev/null +++ b/core/sqlite/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "chat-sqlite" +version = "0.1.0" +edition = "2024" +description = "SQLite storage implementation for libchat" + +[dependencies] +crypto = { path = "../crypto" } +hex = "0.4.3" +storage = { path = "../storage" } +zeroize = { version = "1.8.2", features = ["derive"] } +rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] } + +[dev-dependencies] +tempfile = "3" diff --git a/core/storage/src/sqlite.rs b/core/sqlite/src/common.rs similarity index 66% rename from core/storage/src/sqlite.rs rename to core/sqlite/src/common.rs index c6b00ab..462a74b 100644 --- a/core/storage/src/sqlite.rs +++ b/core/sqlite/src/common.rs @@ -1,9 +1,9 @@ //! SQLite storage backend. use rusqlite::Connection; -use std::path::Path; +use storage::StorageError; -use crate::StorageError; +use crate::errors::map_rusqlite_error; /// Configuration for SQLite storage. #[derive(Debug, Clone)] @@ -28,37 +28,23 @@ impl SqliteDb { /// Creates a new SQLite database with the given configuration. pub fn new(config: StorageConfig) -> Result { let conn = match config { - StorageConfig::InMemory => Connection::open_in_memory()?, - StorageConfig::File(ref path) => Connection::open(path)?, + StorageConfig::InMemory => Connection::open_in_memory().map_err(map_rusqlite_error)?, + StorageConfig::File(ref path) => Connection::open(path).map_err(map_rusqlite_error)?, StorageConfig::Encrypted { ref path, ref key } => { - let conn = Connection::open(path)?; - conn.pragma_update(None, "key", key)?; + let conn = Connection::open(path).map_err(map_rusqlite_error)?; + conn.pragma_update(None, "key", key) + .map_err(map_rusqlite_error)?; conn } }; // Enable foreign keys - conn.execute_batch("PRAGMA foreign_keys = ON;")?; + conn.execute_batch("PRAGMA foreign_keys = ON;") + .map_err(map_rusqlite_error)?; Ok(Self { conn }) } - /// Opens an existing database file. - pub fn open>(path: P) -> Result { - let conn = Connection::open(path)?; - conn.execute_batch("PRAGMA foreign_keys = ON;")?; - Ok(Self { conn }) - } - - /// Creates an in-memory database (useful for testing). - pub fn in_memory() -> Result { - Self::new(StorageConfig::InMemory) - } - - pub fn sqlcipher(path: String, key: String) -> Result { - Self::new(StorageConfig::Encrypted { path, key }) - } - /// Returns a reference to the underlying connection. /// /// Use this for domain-specific storage operations. @@ -75,6 +61,6 @@ impl SqliteDb { /// Begins a transaction. pub fn transaction(&mut self) -> Result, StorageError> { - Ok(self.conn.transaction()?) + self.conn.transaction().map_err(map_rusqlite_error) } } diff --git a/core/sqlite/src/errors.rs b/core/sqlite/src/errors.rs new file mode 100644 index 0000000..ae14566 --- /dev/null +++ b/core/sqlite/src/errors.rs @@ -0,0 +1,24 @@ +use rusqlite::Error as RusqliteError; +use storage::StorageError; + +pub(crate) fn map_rusqlite_error(err: RusqliteError) -> StorageError { + StorageError::Database(err.to_string()) +} + +pub(crate) fn map_optional_row( + result: Result, +) -> Result, StorageError> { + match result { + Ok(value) => Ok(Some(value)), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(map_rusqlite_error(err)), + } +} + +pub(crate) fn not_found(record: impl Into) -> StorageError { + StorageError::NotFound(record.into()) +} + +pub(crate) fn invalid_blob_length(field: &str, expected: usize, actual: usize) -> StorageError { + StorageError::InvalidData(format!("{field} expected {expected} bytes, got {actual}")) +} diff --git a/core/sqlite/src/lib.rs b/core/sqlite/src/lib.rs new file mode 100644 index 0000000..8c57bb3 --- /dev/null +++ b/core/sqlite/src/lib.rs @@ -0,0 +1,652 @@ +//! Chat-specific SQLite storage implementation. + +mod common; +mod errors; +mod migrations; +mod types; + +use std::collections::HashSet; + +use crypto::{Identity, PrivateKey}; +use rusqlite::{Transaction, params}; +use storage::{ + ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore, IdentityStore, + RatchetStateRecord, RatchetStore, SkippedKeyRecord, StorageError, +}; +use zeroize::Zeroize; + +use crate::{ + common::SqliteDb, + errors::{invalid_blob_length, map_optional_row, map_rusqlite_error, not_found}, + types::IdentityRecord, +}; + +pub use common::StorageConfig; + +/// 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_migrations(db) + } + + pub fn in_memory() -> Self { + Self::new(StorageConfig::InMemory).unwrap() + } + + /// Applies all migrations and returns the storage instance. + fn run_migrations(mut db: SqliteDb) -> Result { + migrations::apply_migrations(db.connection_mut())?; + Ok(Self { db }) + } +} + +impl IdentityStore for ChatStorage { + /// Loads the identity if it exists. + /// + /// Note: Secret key bytes are zeroized after being copied into IdentityRecord, + /// which handles its own zeroization via ZeroizeOnDrop. + fn load_identity(&self) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT name, secret_key FROM identity WHERE id = 1") + .map_err(map_rusqlite_error)?; + + let result = stmt.query_row([], |row| { + let name: String = row.get(0)?; + let secret_key: Vec = row.get(1)?; + Ok((name, secret_key)) + }); + + match map_optional_row(result)? { + Some((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(invalid_blob_length( + "identity.secret_key", + 32, + secret_key_vec.len(), + )); + } + }; + secret_key_vec.zeroize(); + let record = IdentityRecord { + name, + secret_key: bytes, + }; + Ok(Some(Identity::from(record))) + } + None => Ok(None), + } + } + + /// 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. + fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { + 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(), secret_bytes.as_slice()], + ) + .map_err(map_rusqlite_error); + secret_bytes.zeroize(); + result?; + Ok(()) + } +} + +impl EphemeralKeyStore for ChatStorage { + /// Saves an ephemeral key pair to storage. + 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()], + ) + .map_err(map_rusqlite_error); + secret_bytes.zeroize(); + result?; + Ok(()) + } + + /// Loads a single ephemeral key by its public key hex. + fn load_ephemeral_key(&self, public_key_hex: &str) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT secret_key FROM ephemeral_keys WHERE public_key_hex = ?1") + .map_err(map_rusqlite_error)?; + + let result = stmt.query_row(params![public_key_hex], |row| { + let secret_key: Vec = row.get(0)?; + Ok(secret_key) + }); + + match map_optional_row(result)? { + Some(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(invalid_blob_length( + "ephemeral_keys.secret_key", + 32, + secret_key_vec.len(), + )); + } + }; + secret_key_vec.zeroize(); + Ok(Some(PrivateKey::from(bytes))) + } + None => Ok(None), + } + } + + /// Removes an ephemeral key from storage. + 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], + ) + .map_err(map_rusqlite_error)?; + Ok(()) + } +} + +impl ConversationStore for ChatStorage { + /// Saves conversation metadata. + fn save_conversation(&mut self, meta: &ConversationMeta) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO conversations (local_convo_id, remote_convo_id, convo_type) VALUES (?1, ?2, ?3)", + params![meta.local_convo_id, meta.remote_convo_id, meta.kind.as_str()], + ) + .map_err(map_rusqlite_error)?; + Ok(()) + } + + /// Loads a single conversation record by its local ID. + 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", + ) + .map_err(map_rusqlite_error)?; + + let result = stmt.query_row(params![local_convo_id], |row| { + let local_convo_id: String = row.get(0)?; + let remote_convo_id: String = row.get(1)?; + let convo_type: String = row.get(2)?; + Ok(ConversationMeta { + local_convo_id, + remote_convo_id, + kind: ConversationKind::from(convo_type.as_str()), + }) + }); + + map_optional_row(result) + } + + /// Removes a conversation by its local ID. + 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], + ) + .map_err(map_rusqlite_error)?; + Ok(()) + } + + /// Loads all conversation records. + fn load_conversations(&self) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT local_convo_id, remote_convo_id, convo_type FROM conversations") + .map_err(map_rusqlite_error)?; + + let records = stmt + .query_map([], |row| { + let local_convo_id: String = row.get(0)?; + let remote_convo_id: String = row.get(1)?; + let convo_type: String = row.get(2)?; + Ok(ConversationMeta { + local_convo_id, + remote_convo_id, + kind: ConversationKind::from(convo_type.as_str()), + }) + }) + .map_err(map_rusqlite_error)? + .collect::, _>>() + .map_err(map_rusqlite_error)?; + + Ok(records) + } + + /// Checks if a conversation exists by its local ID. + 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), + ) + .map_err(map_rusqlite_error)?; + Ok(exists) + } +} + +impl RatchetStore for ChatStorage { + fn save_ratchet_state( + &mut self, + conversation_id: &str, + state: &RatchetStateRecord, + skipped_keys: &[SkippedKeyRecord], + ) -> Result<(), StorageError> { + let tx = self.db.transaction()?; + + // Upsert main state + tx.execute( + " + INSERT INTO ratchet_state ( + conversation_id, root_key, sending_chain, receiving_chain, + dh_self_secret, dh_remote, msg_send, msg_recv, prev_chain_len + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(conversation_id) DO UPDATE SET + root_key = excluded.root_key, + sending_chain = excluded.sending_chain, + receiving_chain = excluded.receiving_chain, + dh_self_secret = excluded.dh_self_secret, + dh_remote = excluded.dh_remote, + msg_send = excluded.msg_send, + msg_recv = excluded.msg_recv, + prev_chain_len = excluded.prev_chain_len + ", + params![ + conversation_id, + state.root_key.as_slice(), + state.sending_chain.as_ref().map(|c| c.as_slice()), + state.receiving_chain.as_ref().map(|c| c.as_slice()), + state.dh_self_secret.as_slice(), + state.dh_remote.as_ref().map(|c| c.as_slice()), + state.msg_send, + state.msg_recv, + state.prev_chain_len, + ], + ) + .map_err(map_rusqlite_error)?; + + // Sync skipped keys + sync_skipped_keys(&tx, conversation_id, skipped_keys)?; + + tx.commit().map_err(map_rusqlite_error)?; + Ok(()) + } + + fn load_ratchet_state( + &self, + conversation_id: &str, + ) -> Result { + let conn = self.db.connection(); + let mut stmt = conn + .prepare( + " + SELECT root_key, sending_chain, receiving_chain, dh_self_secret, + dh_remote, msg_send, msg_recv, prev_chain_len + FROM ratchet_state + WHERE conversation_id = ?1 + ", + ) + .map_err(map_rusqlite_error)?; + + let ( + root_key, + sending_chain, + receiving_chain, + dh_self_secret, + dh_remote, + msg_send, + msg_recv, + prev_chain_len, + ) = stmt + .query_row(params![conversation_id], |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, Option>>(1)?, + row.get::<_, Option>>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, Option>>(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }) + .map_err(|err| match err { + rusqlite::Error::QueryReturnedNoRows => not_found(conversation_id.to_string()), + other => map_rusqlite_error(other), + })?; + + Ok(RatchetStateRecord { + root_key: blob_to_array(root_key, "ratchet_state.root_key")?, + sending_chain: sending_chain + .map(|blob| blob_to_array(blob, "ratchet_state.sending_chain")) + .transpose()?, + receiving_chain: receiving_chain + .map(|blob| blob_to_array(blob, "ratchet_state.receiving_chain")) + .transpose()?, + dh_self_secret: blob_to_array(dh_self_secret, "ratchet_state.dh_self_secret")?, + dh_remote: dh_remote + .map(|blob| blob_to_array(blob, "ratchet_state.dh_remote")) + .transpose()?, + msg_send, + msg_recv, + prev_chain_len, + }) + } + + fn load_skipped_keys( + &self, + conversation_id: &str, + ) -> Result, StorageError> { + let conn = self.db.connection(); + let mut stmt = conn + .prepare( + " + SELECT public_key, msg_num, message_key + FROM skipped_keys + WHERE conversation_id = ?1 + ", + ) + .map_err(map_rusqlite_error)?; + + let rows = stmt + .query_map(params![conversation_id], |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, Vec>(2)?, + )) + }) + .map_err(map_rusqlite_error)? + .collect::, _>>() + .map_err(map_rusqlite_error)?; + + rows.into_iter() + .map(|(public_key, msg_num, message_key)| { + Ok(SkippedKeyRecord { + public_key: blob_to_array(public_key, "skipped_keys.public_key")?, + msg_num, + message_key: blob_to_array(message_key, "skipped_keys.message_key")?, + }) + }) + .collect() + } + + fn has_ratchet_state(&self, conversation_id: &str) -> Result { + let conn = self.db.connection(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM ratchet_state WHERE conversation_id = ?1", + params![conversation_id], + |row| row.get(0), + ) + .map_err(map_rusqlite_error)?; + Ok(count > 0) + } + + fn delete_ratchet_state(&mut self, conversation_id: &str) -> Result<(), StorageError> { + let tx = self.db.transaction()?; + tx.execute( + "DELETE FROM skipped_keys WHERE conversation_id = ?1", + params![conversation_id], + ) + .map_err(map_rusqlite_error)?; + tx.execute( + "DELETE FROM ratchet_state WHERE conversation_id = ?1", + params![conversation_id], + ) + .map_err(map_rusqlite_error)?; + tx.commit().map_err(map_rusqlite_error)?; + Ok(()) + } + + fn cleanup_old_skipped_keys(&mut self, max_age_secs: i64) -> Result { + let conn = self.db.connection(); + let deleted = conn + .execute( + "DELETE FROM skipped_keys WHERE created_at < strftime('%s', 'now') - ?1", + params![max_age_secs], + ) + .map_err(map_rusqlite_error)?; + Ok(deleted) + } +} + +/// Syncs skipped keys efficiently by computing diff and only inserting/deleting changes. +fn sync_skipped_keys( + tx: &Transaction, + conversation_id: &str, + current_keys: &[SkippedKeyRecord], +) -> Result<(), StorageError> { + // Get existing keys from DB (just the identifiers) + let mut stmt = tx + .prepare("SELECT public_key, msg_num FROM skipped_keys WHERE conversation_id = ?1") + .map_err(map_rusqlite_error)?; + let existing_rows = stmt + .query_map(params![conversation_id], |row| { + Ok((row.get::<_, Vec>(0)?, row.get::<_, u32>(1)?)) + }) + .map_err(map_rusqlite_error)? + .collect::, _>>() + .map_err(map_rusqlite_error)?; + + let existing: HashSet<([u8; 32], u32)> = existing_rows + .into_iter() + .map(|(public_key, msg_num)| { + Ok(( + blob_to_array(public_key, "skipped_keys.public_key")?, + msg_num, + )) + }) + .collect::>()?; + + // Build set of current keys + let current_set: HashSet<([u8; 32], u32)> = current_keys + .iter() + .map(|sk| (sk.public_key, sk.msg_num)) + .collect(); + + // Delete keys that were removed (used for decryption) + for (pk, msg_num) in existing.difference(¤t_set) { + tx.execute( + "DELETE FROM skipped_keys WHERE conversation_id = ?1 AND public_key = ?2 AND msg_num = ?3", + params![conversation_id, pk.as_slice(), msg_num], + ) + .map_err(map_rusqlite_error)?; + } + + // Insert new keys + for sk in current_keys { + let key = (sk.public_key, sk.msg_num); + if !existing.contains(&key) { + tx.execute( + "INSERT INTO skipped_keys (conversation_id, public_key, msg_num, message_key) + VALUES (?1, ?2, ?3, ?4)", + params![ + conversation_id, + sk.public_key.as_slice(), + sk.msg_num, + sk.message_key.as_slice(), + ], + ) + .map_err(map_rusqlite_error)?; + } + } + + Ok(()) +} + +fn blob_to_array( + blob: Vec, + field: &'static str, +) -> Result<[u8; N], StorageError> { + let actual = blob.len(); + blob.try_into() + .map_err(|_| invalid_blob_length(field, N, actual)) +} + +#[cfg(test)] +mod tests { + use storage::{ + ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore, IdentityStore, + RatchetStore, + }; + + 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("default"); + let pubkey = identity.public_key(); + storage.save_identity(&identity).unwrap(); + + // Load identity + 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: 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()); + } + + #[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(&ConversationMeta { + local_convo_id: "local_1".into(), + remote_convo_id: "remote_1".into(), + kind: ConversationKind::PrivateV1, + }) + .unwrap(); + storage + .save_conversation(&ConversationMeta { + local_convo_id: "local_2".into(), + remote_convo_id: "remote_2".into(), + kind: ConversationKind::PrivateV1, + }) + .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].kind.as_str(), "private_v1"); + } + + #[test] + fn test_invalid_ratchet_blob_returns_storage_error() { + let storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + storage + .db + .connection() + .execute( + "INSERT INTO ratchet_state ( + conversation_id, root_key, sending_chain, receiving_chain, + dh_self_secret, dh_remote, msg_send, msg_recv, prev_chain_len + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + "bad-convo", + vec![0u8; 31], + Option::>::None, + Option::>::None, + vec![0u8; 32], + Option::>::None, + 0u32, + 0u32, + 0u32, + ], + ) + .map_err(map_rusqlite_error) + .unwrap(); + + let err = storage.load_ratchet_state("bad-convo").unwrap_err(); + assert!(matches!(err, StorageError::InvalidData(_))); + assert_eq!( + err.to_string(), + "invalid data: ratchet_state.root_key expected 32 bytes, got 31" + ); + } +} diff --git a/core/conversations/src/storage/migrations.rs b/core/sqlite/src/migrations.rs similarity index 53% rename from core/conversations/src/storage/migrations.rs rename to core/sqlite/src/migrations.rs index 014bb96..439f9fc 100644 --- a/core/conversations/src/storage/migrations.rs +++ b/core/sqlite/src/migrations.rs @@ -3,14 +3,23 @@ //! SQL migrations are embedded at compile time and applied in order. //! Each migration is applied atomically within a transaction. -use storage::{Connection, StorageError}; +use rusqlite::Connection; +use storage::StorageError; + +use crate::errors::map_rusqlite_error; /// 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_ratchet_state", + include_str!("migrations/002_ratchet_state.sql"), + ), + ] } /// Applies all migrations to the database. @@ -23,22 +32,26 @@ pub fn apply_migrations(conn: &mut Connection) -> Result<(), StorageError> { name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) );", - )?; + ) + .map_err(map_rusqlite_error)?; 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), - )?; + let already_applied: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM _migrations WHERE name = ?1)", + [name], + |row| row.get(0), + ) + .map_err(map_rusqlite_error)?; if !already_applied { // 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()?; + let tx = conn.transaction().map_err(map_rusqlite_error)?; + tx.execute_batch(sql).map_err(map_rusqlite_error)?; + tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) + .map_err(map_rusqlite_error)?; + tx.commit().map_err(map_rusqlite_error)?; } } diff --git a/core/sqlite/src/migrations/001_initial_schema.sql b/core/sqlite/src/migrations/001_initial_schema.sql new file mode 100644 index 0000000..69ec08b --- /dev/null +++ b/core/sqlite/src/migrations/001_initial_schema.sql @@ -0,0 +1,23 @@ +-- 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 +); + +-- Ephemeral keys for inbox handshakes +CREATE TABLE IF NOT EXISTS ephemeral_keys ( + public_key_hex TEXT PRIMARY KEY, + secret_key BLOB NOT NULL +); + +-- Conversations metadata +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/core/sqlite/src/migrations/002_ratchet_state.sql b/core/sqlite/src/migrations/002_ratchet_state.sql new file mode 100644 index 0000000..aa08602 --- /dev/null +++ b/core/sqlite/src/migrations/002_ratchet_state.sql @@ -0,0 +1,27 @@ +-- Ratchet state tables +-- Migration: 002_ratchet_state + +CREATE TABLE IF NOT EXISTS ratchet_state ( + conversation_id TEXT PRIMARY KEY, + root_key BLOB NOT NULL, + sending_chain BLOB, + receiving_chain BLOB, + dh_self_secret BLOB NOT NULL, + dh_remote BLOB, + msg_send INTEGER NOT NULL, + msg_recv INTEGER NOT NULL, + prev_chain_len INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS skipped_keys ( + conversation_id TEXT NOT NULL, + public_key BLOB NOT NULL, + msg_num INTEGER NOT NULL, + message_key BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (conversation_id, public_key, msg_num), + FOREIGN KEY (conversation_id) REFERENCES ratchet_state(conversation_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_skipped_keys_conversation + ON skipped_keys(conversation_id); diff --git a/core/conversations/src/storage/types.rs b/core/sqlite/src/types.rs similarity index 96% rename from core/conversations/src/storage/types.rs rename to core/sqlite/src/types.rs index c34f9be..786bc54 100644 --- a/core/conversations/src/storage/types.rs +++ b/core/sqlite/src/types.rs @@ -2,8 +2,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::crypto::PrivateKey; -use crate::identity::Identity; +use crypto::{Identity, PrivateKey}; /// Record for storing identity (secret key). /// Implements ZeroizeOnDrop to securely clear secret key from memory. diff --git a/core/storage/Cargo.toml b/core/storage/Cargo.toml index 40d11d6..b176087 100644 --- a/core/storage/Cargo.toml +++ b/core/storage/Cargo.toml @@ -5,5 +5,5 @@ edition = "2024" description = "Shared storage layer for libchat" [dependencies] +crypto = { path = "../crypto" } thiserror = "2" -rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] } diff --git a/core/storage/src/errors.rs b/core/storage/src/errors.rs index 9d65d64..4239222 100644 --- a/core/storage/src/errors.rs +++ b/core/storage/src/errors.rs @@ -11,29 +11,7 @@ pub enum StorageError { #[error("not found: {0}")] NotFound(String), - /// Serialization error. - #[error("serialization error: {0}")] - Serialization(String), - - /// Deserialization error. - #[error("deserialization error: {0}")] - Deserialization(String), - - /// Schema migration error. - #[error("migration error: {0}")] - Migration(String), - - /// Transaction error. - #[error("transaction error: {0}")] - Transaction(String), - /// Invalid data error. #[error("invalid data: {0}")] InvalidData(String), } - -impl From for StorageError { - fn from(e: rusqlite::Error) -> Self { - StorageError::Database(e.to_string()) - } -} diff --git a/core/storage/src/lib.rs b/core/storage/src/lib.rs index 9240dc2..6c8f367 100644 --- a/core/storage/src/lib.rs +++ b/core/storage/src/lib.rs @@ -3,13 +3,13 @@ //! This crate provides a common storage abstraction that can be used by //! multiple crates in the libchat workspace (double-ratchets, conversations, etc.). //! -//! Uses SQLCipher for encrypted SQLite storage. +//! The storage implementation is handled by other crates. mod errors; -mod sqlite; +mod store; pub use errors::StorageError; -pub use sqlite::{SqliteDb, StorageConfig}; - -// Re-export rusqlite types that domain crates will need -pub use rusqlite::{Connection, Error as RusqliteError, Transaction, params}; +pub use store::{ + ChatStore, ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore, + IdentityStore, RatchetStateRecord, RatchetStore, SkippedKeyRecord, +}; diff --git a/core/storage/src/store.rs b/core/storage/src/store.rs new file mode 100644 index 0000000..a24ad25 --- /dev/null +++ b/core/storage/src/store.rs @@ -0,0 +1,126 @@ +use crypto::{Identity, PrivateKey}; + +use crate::StorageError; + +/// Persistence operations for installation identity data. +pub trait IdentityStore { + /// Loads the stored identity if one exists. + fn load_identity(&self) -> Result, StorageError>; + + /// Persists the installation identity. + fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError>; +} + +pub trait EphemeralKeyStore { + fn save_ephemeral_key( + &mut self, + public_key_hex: &str, + private_key: &PrivateKey, + ) -> Result<(), StorageError>; + + fn load_ephemeral_key(&self, public_key_hex: &str) -> Result, StorageError>; + + fn remove_ephemeral_key(&mut self, public_key_hex: &str) -> Result<(), StorageError>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConversationKind { + PrivateV1, + Unknown(String), +} + +impl ConversationKind { + pub fn as_str(&self) -> &str { + match self { + Self::PrivateV1 => "private_v1", + Self::Unknown(value) => value.as_str(), + } + } +} + +impl From<&str> for ConversationKind { + fn from(value: &str) -> Self { + match value { + "private_v1" => Self::PrivateV1, + other => Self::Unknown(other.to_string()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationMeta { + pub local_convo_id: String, + pub remote_convo_id: String, + pub kind: ConversationKind, +} + +pub trait ConversationStore { + fn save_conversation(&mut self, meta: &ConversationMeta) -> Result<(), StorageError>; + + fn load_conversation( + &self, + local_convo_id: &str, + ) -> Result, StorageError>; + + fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError>; + + fn load_conversations(&self) -> Result, StorageError>; + + fn has_conversation(&self, local_convo_id: &str) -> Result; +} + +/// Raw state data for ratchet storage (without generic parameter). +#[derive(Debug, Clone)] +pub struct RatchetStateRecord { + pub root_key: [u8; 32], + pub sending_chain: Option<[u8; 32]>, + pub receiving_chain: Option<[u8; 32]>, + pub dh_self_secret: [u8; 32], + pub dh_remote: Option<[u8; 32]>, + pub msg_send: u32, + pub msg_recv: u32, + pub prev_chain_len: u32, +} + +/// A skipped message key stored alongside ratchet state. +#[derive(Debug, Clone)] +pub struct SkippedKeyRecord { + pub public_key: [u8; 32], + pub msg_num: u32, + pub message_key: [u8; 32], +} + +/// Persistence operations for double-ratchet state. +pub trait RatchetStore { + /// Saves ratchet state and skipped keys for a conversation. + fn save_ratchet_state( + &mut self, + conversation_id: &str, + state: &RatchetStateRecord, + skipped_keys: &[SkippedKeyRecord], + ) -> Result<(), StorageError>; + + /// Loads ratchet state for a conversation. + fn load_ratchet_state(&self, conversation_id: &str) + -> Result; + + /// Loads skipped keys for a conversation. + fn load_skipped_keys( + &self, + conversation_id: &str, + ) -> Result, StorageError>; + + /// Checks if a ratchet state exists for a conversation. + fn has_ratchet_state(&self, conversation_id: &str) -> Result; + + /// Deletes ratchet state and skipped keys for a conversation. + fn delete_ratchet_state(&mut self, conversation_id: &str) -> Result<(), StorageError>; + + /// Cleans up old skipped keys older than the given age in seconds. + fn cleanup_old_skipped_keys(&mut self, max_age_secs: i64) -> Result; +} + +pub trait ChatStore: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore {} + +impl ChatStore for T where T: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore +{} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index d3cfb2a..d647111 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["rlib"] [dependencies] libchat = { workspace = true } +chat-sqlite = { path = "../../core/sqlite" } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index a26908a..6bd245a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,14 +1,18 @@ +use chat_sqlite::StorageConfig; use libchat::ChatError; +use libchat::ChatStorage; use libchat::Context; pub struct ChatClient { - ctx: Context, + ctx: Context, } impl ChatClient { pub fn new(name: impl Into) -> Self { + let store = + ChatStorage::new(StorageConfig::InMemory).expect("in-memory storage should not fail"); Self { - ctx: Context::new_with_name(name), + ctx: Context::new_with_name(name, store), } } diff --git a/nim-bindings/src/bindings.nim b/nim-bindings/src/bindings.nim index eb6053e..b1c61da 100644 --- a/nim-bindings/src/bindings.nim +++ b/nim-bindings/src/bindings.nim @@ -40,9 +40,8 @@ type VecPayload* = object `ptr`*: ptr Payload len*: csize_t - cap*: csize_t + cap*: csize_t ## Vector of Payloads returned by safer_ffi functions - ## Vector of Payloads returned by safer_ffi functions VecString* = object `ptr`*: ptr ReprCString len*: csize_t @@ -104,33 +103,25 @@ proc destroy_string*(s: ReprCString) {.importc.} ## Creates an intro bundle for sharing with other users ## Returns: CreateIntroResult struct - check error_code field (0 = success, negative = error) ## The result must be freed with destroy_intro_result() -proc create_intro_bundle*( - ctx: ContextHandle, -): CreateIntroResult {.importc.} +proc create_intro_bundle*(ctx: ContextHandle): CreateIntroResult {.importc.} ## Creates a new private conversation ## Returns: NewConvoResult struct - check error_code field (0 = success, negative = error) ## The result must be freed with destroy_convo_result() proc create_new_private_convo*( - ctx: ContextHandle, - bundle: SliceUint8, - content: SliceUint8, + ctx: ContextHandle, bundle: SliceUint8, content: SliceUint8 ): NewConvoResult {.importc.} ## Get the available conversation identifers. ## Returns: ListConvoResult struct - check error_code field (0 = success, negative = error) ## The result must be freed with destroy_list_result() -proc list_conversations*( - ctx: ContextHandle, -): ListConvoResult {.importc.} +proc list_conversations*(ctx: ContextHandle): ListConvoResult {.importc.} ## Sends content to an existing conversation ## Returns: SendContentResult struct - check error_code field (0 = success, negative = error) ## The result must be freed with destroy_send_content_result() proc send_content*( - ctx: ContextHandle, - convo_id: ReprCString, - content: SliceUint8, + ctx: ContextHandle, convo_id: ReprCString, content: SliceUint8 ): SendContentResult {.importc.} ## Handles an incoming payload @@ -139,8 +130,7 @@ proc send_content*( ## is no data, and the convo_id should be ignored. ## The result must be freed with destroy_handle_payload_result() proc handle_payload*( - ctx: ContextHandle, - payload: SliceUint8, + ctx: ContextHandle, payload: SliceUint8 ): HandlePayloadResult {.importc.} ## Free the result from create_intro_bundle @@ -229,4 +219,3 @@ proc toBytes*(s: string): seq[byte] = return @[] result = newSeq[byte](s.len) copyMem(addr result[0], unsafeAddr s[0], s.len) -