mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-04-01 17:13:13 +00:00
Merge 961697e2c53225e6245c6dc2c33e6033d0df5860 into 8cddd9ddcfb446deeff96fd5a68d6e4b14927d9f
This commit is contained in:
commit
ef61a4e9bb
30
Cargo.lock
generated
30
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
resolver = "3"
|
||||
|
||||
members = [
|
||||
"core/sqlite",
|
||||
"core/conversations",
|
||||
"core/crypto",
|
||||
"core/double-ratchets",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
@ -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,
|
||||
@ -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};
|
||||
|
||||
@ -27,4 +27,5 @@ serde = "1.0"
|
||||
headers = ["safer-ffi/headers"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
sqlite = { package = "chat-sqlite", path = "../sqlite" }
|
||||
tempfile = "3"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(¤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<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());
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
15
core/sqlite/Cargo.toml
Normal 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"
|
||||
@ -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
24
core/sqlite/src/errors.rs
Normal 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
652
core/sqlite/src/lib.rs
Normal 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(¤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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
23
core/sqlite/src/migrations/001_initial_schema.sql
Normal file
23
core/sqlite/src/migrations/001_initial_schema.sql
Normal 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'))
|
||||
);
|
||||
27
core/sqlite/src/migrations/002_ratchet_state.sql
Normal file
27
core/sqlite/src/migrations/002_ratchet_state.sql
Normal 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);
|
||||
@ -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.
|
||||
@ -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"] }
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
126
core/storage/src/store.rs
Normal 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
|
||||
{}
|
||||
@ -8,3 +8,4 @@ crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
libchat = { workspace = true }
|
||||
chat-sqlite = { path = "../../core/sqlite" }
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user