Merge 961697e2c53225e6245c6dc2c33e6033d0df5860 into 8cddd9ddcfb446deeff96fd5a68d6e4b14927d9f

This commit is contained in:
kaichao 2026-03-31 11:40:49 +08:00 committed by GitHub
commit ef61a4e9bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1419 additions and 846 deletions

30
Cargo.lock generated
View File

@ -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",
]

View File

@ -3,6 +3,7 @@
resolver = "3"
members = [
"core/sqlite",
"core/conversations",
"core/crypto",
"core/double-ratchets",

View File

@ -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"

View File

@ -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<ChatStorage>);
/// 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<ContextHandle> {
// 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<Payload> = payloads

View File

@ -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<T: ChatStore> {
_identity: Rc<Identity>,
store: ConversationStore,
inbox: Inbox,
#[allow(dead_code)] // Will be used for conversation persistence
storage: ChatStorage,
store: T,
}
impl Context {
impl<T: ChatStore> Context<T> {
/// 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<String>, config: StorageConfig) -> Result<Self, ChatError> {
let mut storage = ChatStorage::new(config)?;
pub fn new_from_store(name: impl Into<String>, mut store: T) -> Result<Self, ChatError> {
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<String>) -> Self {
Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail")
pub fn new_with_name(name: impl Into<String>, 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<AddressedEnvelope>) {
) -> Result<(ConversationIdOwned, Vec<AddressedEnvelope>), 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<Vec<ConversationIdOwned>, 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<Vec<AddressedEnvelope>, 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<Option<ContentData>, 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<Option<ContentData>, 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<Vec<u8>, ChatError> {
Ok(self.inbox.create_intro_bundle().into())
}
fn add_convo(&mut self, convo: Box<dyn Convo>) -> 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<Conversation, ChatError> {
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<ConversationIdOwned, ChatError> {
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<ChatStorage>,
receiver: &mut Context<ChatStorage>,
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");
}
}

View File

@ -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<str>;
@ -27,44 +31,11 @@ pub trait Convo: Id + Debug {
) -> Result<Option<ContentData>, ChatError>;
fn remote_id(&self) -> String;
/// Returns the conversation type identifier for storage.
fn convo_type(&self) -> ConversationKind;
}
pub struct ConversationStore {
conversations: HashMap<Arc<str>, Box<dyn Convo>>,
pub enum Conversation {
Private(PrivateV1Convo),
}
impl ConversationStore {
pub fn new() -> Self {
Self {
conversations: HashMap::new(),
}
}
pub fn insert_convo(&mut self, conversation: Box<dyn Convo>) -> 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<ConversationIdOwned> {
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;

View File

@ -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<T: RatchetStore>(&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 {

View File

@ -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),
}

View File

@ -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<Identity>,
local_convo_id: String,
ephemeral_keys: HashMap<String, PrivateKey>,
}
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::<String, PrivateKey>::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<dyn Convo>, Option<ContentData>), ChatError> {
) -> Result<(Conversation, Option<ContentData>), 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<String, ChatError> {
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(),

View File

@ -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 {

View File

@ -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<Self, StorageError> {
let db = SqliteDb::new(config)?;
Self::run_migrations(db)
}
/// Applies all migrations and returns the storage instance.
fn run_migrations(mut db: SqliteDb) -> Result<Self, StorageError> {
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<Option<Identity>, 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<u8> = 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);
}
}

View File

@ -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
);

View File

@ -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,

View File

@ -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};

View File

@ -27,4 +27,5 @@ serde = "1.0"
headers = ["safer-ffi/headers"]
[dev-dependencies]
tempfile = "3"
sqlite = { package = "chat-sqlite", path = "../sqlite" }
tempfile = "3"

View File

@ -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<u8>, 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<ChatStorage> = 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<ChatStorage> = 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<ChatStorage> =
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<ChatStorage> =
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<ChatStorage> =
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<ChatStorage> =
RatchetSession::open(&mut bob_storage, conv_id).unwrap();
match bob_session.decrypt_message(&ct4, header4) {

View File

@ -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<ChatStorage> = 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<ChatStorage> =
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<ChatStorage> =
RatchetSession::open(alice_storage, conv_id).unwrap();
let mut bob_session: RatchetSession<ChatStorage> =
RatchetSession::open(bob_storage, conv_id).unwrap();
println!(" Sessions restored for Alice and Bob",);
// Continue conversation

View File

@ -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,
};

View File

@ -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<Self, StorageError> {
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<Self, StorageError> {
let db = SqliteDb::in_memory()?;
Self::run_migration(db)
}
/// Creates a new ratchet storage with the given database.
fn run_migration(db: SqliteDb) -> Result<Self, StorageError> {
// Initialize schema
db.connection().execute_batch(RATCHET_SCHEMA)?;
Ok(Self { db })
}
/// Saves the ratchet state for a conversation.
pub fn save<D: HkdfInfo>(
&mut self,
conversation_id: &str,
state: &RatchetState<D>,
) -> Result<(), StorageError> {
let tx = self.db.transaction()?;
let data = RatchetStateRecord::from(state);
let skipped_keys: Vec<SkippedKey> = 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<D: HkdfInfo>(
&self,
conversation_id: &str,
) -> Result<RatchetState<D>, 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<RatchetStateRecord, StorageError> {
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<u8>>(0)?),
sending_chain: row.get::<_, Option<Vec<u8>>>(1)?.map(blob_to_array),
receiving_chain: row.get::<_, Option<Vec<u8>>>(2)?.map(blob_to_array),
dh_self_secret: blob_to_array(row.get::<_, Vec<u8>>(3)?),
dh_remote: row.get::<_, Option<Vec<u8>>>(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<Vec<SkippedKey>, 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<u8>>(0)?),
msg_num: row.get(1)?,
message_key: blob_to_array(row.get::<_, Vec<u8>>(2)?),
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| StorageError::Database(e.to_string()))
}
/// Checks if a conversation exists.
pub fn exists(&self, conversation_id: &str) -> Result<bool, StorageError> {
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<usize, StorageError> {
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<SkippedKey>,
) -> 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<u8>>(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(&current_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 &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(),
],
)?;
}
}
Ok(())
}
fn blob_to_array<const N: usize>(blob: Vec<u8>) -> [u8; N] {
blob.try_into()
.unwrap_or_else(|v: Vec<u8>| 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());
}
}

View File

@ -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};

View File

@ -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<D>,
}
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<String>,
) -> Result<Self, SessionError> {
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<String>,
state: RatchetState<D>,
) -> Result<Self, SessionError> {
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<Self, SessionError> {
if storage.exists(conversation_id)? {
if storage.has_ratchet_state(conversation_id)? {
return Err(SessionError::ConvAlreadyExists(conversation_id.to_string()));
}
let state = RatchetState::<D>::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<Self, SessionError> {
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<S: RatchetStore, D: HkdfInfo>(
storage: &mut S,
conversation_id: &str,
state: &RatchetState<D>,
) -> 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<DefaultDomain> =
let session: RatchetSession<ChatStorage, DefaultDomain> =
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<DefaultDomain> =
let session: RatchetSession<ChatStorage, DefaultDomain> =
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<DefaultDomain> =
let mut session: RatchetSession<ChatStorage, DefaultDomain> =
RatchetSession::open(&mut storage, "bob").unwrap();
session.encrypt_message(b"Hi Alice").unwrap()
};
// Alice receives
let plaintext2 = {
let mut session: RatchetSession<DefaultDomain> =
let mut session: RatchetSession<ChatStorage, DefaultDomain> =
RatchetSession::open(&mut storage, "alice").unwrap();
session.decrypt_message(&ct2, header2).unwrap()
};
@ -259,26 +271,27 @@ mod tests {
// First call creates
{
let session: RatchetSession<DefaultDomain> = RatchetSession::create_sender_session(
&mut storage,
"conv1",
shared_secret,
bob_pub,
)
.unwrap();
let session: RatchetSession<ChatStorage, DefaultDomain> =
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<DefaultDomain> =
let mut session: RatchetSession<ChatStorage, DefaultDomain> =
RatchetSession::open(&mut storage, "conv1").unwrap();
session.encrypt_message(b"test").unwrap();
}
// Verify persistence
{
let session: RatchetSession<DefaultDomain> =
let session: RatchetSession<ChatStorage, DefaultDomain> =
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<DefaultDomain> = RatchetSession::create_sender_session(
&mut storage,
"conv1",
shared_secret,
bob_pub,
)
.unwrap();
let _session: RatchetSession<ChatStorage, DefaultDomain> =
RatchetSession::create_sender_session(
&mut storage,
"conv1",
shared_secret,
bob_pub,
)
.unwrap();
}
// Second creation should fail with ConversationAlreadyExists
{
let result: Result<RatchetSession<DefaultDomain>, _> =
let result: Result<RatchetSession<ChatStorage, DefaultDomain>, _> =
RatchetSession::create_sender_session(
&mut storage,
"conv1",
@ -326,19 +340,20 @@ mod tests {
// First creation succeeds
{
let _session: RatchetSession<DefaultDomain> = RatchetSession::create_receiver_session(
&mut storage,
"conv1",
shared_secret,
bob_keypair,
)
.unwrap();
let _session: RatchetSession<ChatStorage, DefaultDomain> =
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<RatchetSession<DefaultDomain>, _> =
let result: Result<RatchetSession<ChatStorage, DefaultDomain>, _> =
RatchetSession::create_receiver_session(
&mut storage,
"conv1",

View File

@ -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<D: HkdfInfo> From<&RatchetState<D>> for RatchetStateRecord {
fn from(state: &RatchetState<D>) -> 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<D: HkdfInfo>(state: &RatchetState<D>) -> 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<D: HkdfInfo>(self, skipped_keys: Vec<SkippedKey>) -> RatchetState<D> {
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<D: HkdfInfo>(
record: RatchetStateRecord,
skipped_keys: Vec<SkippedKeyRecord>,
) -> RatchetState<D> {
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<SkippedKeyRecord> {
keys.iter()
.map(|sk| SkippedKeyRecord {
public_key: sk.public_key,
msg_num: sk.msg_num,
message_key: sk.message_key,
})
.collect()
}

15
core/sqlite/Cargo.toml Normal file
View File

@ -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"

View File

@ -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<Self, StorageError> {
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<P: AsRef<Path>>(path: P) -> Result<Self, StorageError> {
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, StorageError> {
Self::new(StorageConfig::InMemory)
}
pub fn sqlcipher(path: String, key: String) -> Result<Self, StorageError> {
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<rusqlite::Transaction<'_>, StorageError> {
Ok(self.conn.transaction()?)
self.conn.transaction().map_err(map_rusqlite_error)
}
}

24
core/sqlite/src/errors.rs Normal file
View File

@ -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<T>(
result: Result<T, RusqliteError>,
) -> Result<Option<T>, 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<String>) -> 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}"))
}

652
core/sqlite/src/lib.rs Normal file
View File

@ -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<Self, StorageError> {
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<Self, StorageError> {
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<Option<Identity>, 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<u8> = 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<Option<PrivateKey>, 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<u8> = 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<Option<ConversationMeta>, 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<Vec<ConversationMeta>, 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::<Result<Vec<_>, _>>()
.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<bool, StorageError> {
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<RatchetStateRecord, StorageError> {
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<u8>>(0)?,
row.get::<_, Option<Vec<u8>>>(1)?,
row.get::<_, Option<Vec<u8>>>(2)?,
row.get::<_, Vec<u8>>(3)?,
row.get::<_, Option<Vec<u8>>>(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<Vec<SkippedKeyRecord>, 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<u8>>(0)?,
row.get::<_, u32>(1)?,
row.get::<_, Vec<u8>>(2)?,
))
})
.map_err(map_rusqlite_error)?
.collect::<Result<Vec<_>, _>>()
.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<bool, StorageError> {
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<usize, StorageError> {
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<u8>>(0)?, row.get::<_, u32>(1)?))
})
.map_err(map_rusqlite_error)?
.collect::<Result<Vec<_>, _>>()
.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::<Result<_, StorageError>>()?;
// 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(&current_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<const N: usize>(
blob: Vec<u8>,
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::<Vec<u8>>::None,
Option::<Vec<u8>>::None,
vec![0u8; 32],
Option::<Vec<u8>>::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"
);
}
}

View File

@ -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)?;
}
}

View File

@ -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'))
);

View File

@ -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);

View File

@ -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.

View File

@ -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"] }

View File

@ -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<rusqlite::Error> for StorageError {
fn from(e: rusqlite::Error) -> Self {
StorageError::Database(e.to_string())
}
}

View File

@ -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,
};

126
core/storage/src/store.rs Normal file
View File

@ -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<Option<Identity>, 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<Option<PrivateKey>, 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<Option<ConversationMeta>, StorageError>;
fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError>;
fn load_conversations(&self) -> Result<Vec<ConversationMeta>, StorageError>;
fn has_conversation(&self, local_convo_id: &str) -> Result<bool, StorageError>;
}
/// 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<RatchetStateRecord, StorageError>;
/// Loads skipped keys for a conversation.
fn load_skipped_keys(
&self,
conversation_id: &str,
) -> Result<Vec<SkippedKeyRecord>, StorageError>;
/// Checks if a ratchet state exists for a conversation.
fn has_ratchet_state(&self, conversation_id: &str) -> Result<bool, StorageError>;
/// 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<usize, StorageError>;
}
pub trait ChatStore: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore {}
impl<T> ChatStore for T where T: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore
{}

View File

@ -8,3 +8,4 @@ crate-type = ["rlib"]
[dependencies]
libchat = { workspace = true }
chat-sqlite = { path = "../../core/sqlite" }

View File

@ -1,14 +1,18 @@
use chat_sqlite::StorageConfig;
use libchat::ChatError;
use libchat::ChatStorage;
use libchat::Context;
pub struct ChatClient {
ctx: Context,
ctx: Context<ChatStorage>,
}
impl ChatClient {
pub fn new(name: impl Into<String>) -> 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),
}
}

View File

@ -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)