feat: abstract double ratchet traits

This commit is contained in:
kaichaosun 2026-03-27 19:18:55 +08:00
parent 4cb2ec1608
commit cbcf5e3eb6
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
8 changed files with 175 additions and 38 deletions

View File

@ -1,7 +1,7 @@
use std::rc::Rc;
use std::sync::Arc;
use double_ratchets::{RatchetState, RatchetStorage};
use double_ratchets::{DefaultRatchetStore, RatchetState, RatchetStorage};
use storage::StorageConfig;
use crate::{
@ -12,7 +12,8 @@ use crate::{
proto::{EncryptedPayload, EnvelopeV1, Message},
storage::ChatStorage,
store::{
ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore, IdentityStore,
ChatStore, ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore,
IdentityStore,
},
types::{AddressedEnvelope, ContentData},
};
@ -22,21 +23,27 @@ 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<S = ChatStorage, R = RatchetStorage>
where
S: ChatStore,
R: DefaultRatchetStore,
{
_identity: Rc<Identity>,
inbox: Inbox,
storage: ChatStorage,
ratchet_storage: RatchetStorage,
storage: S,
ratchet_storage: R,
}
impl Context {
/// Opens or creates a Context with the given storage configuration.
///
/// If an identity exists in storage, it will be restored.
/// Otherwise, a new identity will be created with the given name and saved.
pub fn open(name: impl Into<String>, config: StorageConfig) -> Result<Self, ChatError> {
let mut storage = ChatStorage::new(config.clone())?;
let ratchet_storage = RatchetStorage::from_config(config)?;
impl<S, R> Context<S, R>
where
S: ChatStore,
R: DefaultRatchetStore,
{
pub fn with_stores(
name: impl Into<String>,
mut storage: S,
ratchet_storage: R,
) -> Result<Self, ChatError> {
let name = name.into();
// Load or create identity
@ -59,17 +66,35 @@ impl Context {
})
}
pub fn installation_name(&self) -> &str {
self._identity.get_name()
}
}
impl Context<ChatStorage, RatchetStorage> {
/// 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 storage = ChatStorage::new(config.clone())?;
let ratchet_storage = RatchetStorage::from_config(config)?;
Self::with_stores(name, storage, ratchet_storage)
}
/// Creates a new in-memory Context (for testing).
///
/// Uses in-memory SQLite database. Each call creates a new isolated database.
pub fn new_with_name(name: impl Into<String>) -> Self {
Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail")
}
}
pub fn installation_name(&self) -> &str {
self._identity.get_name()
}
impl<S, R> Context<S, R>
where
S: ChatStore,
R: DefaultRatchetStore,
{
pub fn create_private_convo(
&mut self,
remote_bundle: &Introduction,
@ -177,7 +202,8 @@ impl Context {
match meta.kind {
ConversationKind::PrivateV1 => {
let dr_state: RatchetState = self.ratchet_storage.load(&meta.local_convo_id)?;
let dr_state: RatchetState =
DefaultRatchetStore::load_default(&self.ratchet_storage, &meta.local_convo_id)?;
Ok(PrivateV1Convo::from_stored(
meta.local_convo_id,
@ -207,12 +233,17 @@ impl Context {
mod tests {
use super::*;
fn send_and_verify(
sender: &mut Context,
receiver: &mut Context,
fn send_and_verify<S1, R1, S2, R2>(
sender: &mut Context<S1, R1>,
receiver: &mut Context<S2, R2>,
convo_id: ConversationId,
content: &[u8],
) {
) where
S1: ChatStore,
R1: DefaultRatchetStore,
S2: ChatStore,
R2: DefaultRatchetStore,
{
let payloads = sender.send_content(convo_id, content).unwrap();
let payload = payloads.first().unwrap();
let received = receiver

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
pub use crate::errors::ChatError;
use crate::types::{AddressedEncryptedPayload, ContentData};
use double_ratchets::RatchetStorage;
use double_ratchets::DefaultRatchetStore;
pub type ConversationId<'a> = &'a str;
pub type ConversationIdOwned = Arc<str>;
@ -32,7 +32,7 @@ pub trait Convo: Id + Debug {
fn convo_type(&self) -> &str;
/// Persists ratchet state to storage. Default is no-op.
fn save_ratchet_state(&self, _storage: &mut RatchetStorage) -> Result<(), ChatError> {
fn save_ratchet_state(&self, _storage: &mut dyn DefaultRatchetStore) -> Result<(), ChatError> {
Ok(())
}
}

View File

@ -7,7 +7,7 @@ use chat_proto::logoschat::{
encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption},
};
use crypto::{PrivateKey, PublicKey, SymmetricKey32};
use double_ratchets::{Header, InstallationKeyPair, RatchetState};
use double_ratchets::{DefaultRatchetStore, Header, InstallationKeyPair, RatchetState};
use prost::{Message, bytes::Bytes};
use std::fmt::Debug;
@ -18,7 +18,6 @@ use crate::{
types::{AddressedEncryptedPayload, ContentData},
utils::timestamp_millis,
};
use double_ratchets::RatchetStorage;
// Represents the potential participant roles in this Conversation
enum Role {
@ -228,8 +227,8 @@ impl Convo for PrivateV1Convo {
"private_v1"
}
fn save_ratchet_state(&self, storage: &mut RatchetStorage) -> Result<(), ChatError> {
storage.save(&self.local_convo_id, &self.dr_state)?;
fn save_ratchet_state(&self, storage: &mut dyn DefaultRatchetStore) -> Result<(), ChatError> {
storage.save_default(&self.local_convo_id, &self.dr_state)?;
Ok(())
}
}

View File

@ -6,9 +6,11 @@ pub mod keypair;
pub mod reader;
pub mod state;
pub mod storage;
pub mod store;
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 store::DefaultRatchetStore;

View File

@ -8,6 +8,7 @@ use super::types::RatchetStateRecord;
use crate::{
hkdf::HkdfInfo,
state::{RatchetState, SkippedKey},
store::RatchetStore,
};
/// Schema for ratchet state tables.
@ -220,6 +221,28 @@ impl RatchetStorage {
}
}
impl RatchetStore for RatchetStorage {
fn save<D: HkdfInfo>(
&mut self,
conversation_id: &str,
state: &RatchetState<D>,
) -> Result<(), StorageError> {
RatchetStorage::save(self, conversation_id, state)
}
fn load<D: HkdfInfo>(&self, conversation_id: &str) -> Result<RatchetState<D>, StorageError> {
RatchetStorage::load(self, conversation_id)
}
fn exists(&self, conversation_id: &str) -> Result<bool, StorageError> {
RatchetStorage::exists(self, conversation_id)
}
fn delete(&mut self, conversation_id: &str) -> Result<(), StorageError> {
RatchetStorage::delete(self, conversation_id)
}
}
/// Syncs skipped keys efficiently by computing diff and only inserting/deleting changes.
fn sync_skipped_keys(
tx: &storage::Transaction,

View File

@ -9,20 +9,24 @@ use crate::{
types::SharedSecret,
};
use super::RatchetStorage;
use crate::store::RatchetStore;
/// 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,
D: HkdfInfo + Clone = DefaultDomain,
S: RatchetStore = crate::RatchetStorage,
> {
storage: &'a mut S,
conversation_id: String,
state: RatchetState<D>,
}
impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> {
impl<'a, D: HkdfInfo + Clone, S: RatchetStore> RatchetSession<'a, D, S> {
/// 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();
@ -36,7 +40,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> {
/// Creates a new session and persists the initial state.
pub fn create(
storage: &'a mut RatchetStorage,
storage: &'a mut S,
conversation_id: impl Into<String>,
state: RatchetState<D>,
) -> Result<Self, SessionError> {
@ -51,7 +55,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> {
/// Initializes a new session as a sender and persists the initial state.
pub fn create_sender_session(
storage: &'a mut RatchetStorage,
storage: &'a mut S,
conversation_id: &str,
shared_secret: SharedSecret,
remote_pub: PublicKey,
@ -65,7 +69,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> {
/// Initializes a new session as a receiver and persists the initial state.
pub fn create_receiver_session(
storage: &'a mut RatchetStorage,
storage: &'a mut S,
conversation_id: &str,
shared_secret: SharedSecret,
dh_self: InstallationKeyPair,
@ -158,8 +162,8 @@ mod tests {
use super::*;
use crate::hkdf::DefaultDomain;
fn create_test_storage() -> RatchetStorage {
RatchetStorage::in_memory().unwrap()
fn create_test_storage() -> crate::RatchetStorage {
crate::RatchetStorage::in_memory().unwrap()
}
#[test]

View File

@ -0,0 +1,3 @@
mod ratchets;
pub use ratchets::{DefaultRatchetStore, RatchetStore};

View File

@ -0,0 +1,75 @@
use storage::StorageError;
use crate::{
hkdf::{DefaultDomain, HkdfInfo},
state::RatchetState,
};
/// Persistence operations for Double Ratchet conversation state.
pub trait RatchetStore {
/// Saves the ratchet state for a conversation.
fn save<D: HkdfInfo>(
&mut self,
conversation_id: &str,
state: &RatchetState<D>,
) -> Result<(), StorageError>;
/// Loads the ratchet state for a conversation.
fn load<D: HkdfInfo>(&self, conversation_id: &str) -> Result<RatchetState<D>, StorageError>;
/// Checks whether a ratchet state exists for the conversation.
fn exists(&self, conversation_id: &str) -> Result<bool, StorageError>;
/// Deletes the ratchet state and any related skipped keys for the conversation.
fn delete(&mut self, conversation_id: &str) -> Result<(), StorageError>;
}
/// Object-safe ratchet storage operations for the default HKDF domain.
///
/// This is useful for crates that need dynamic dispatch, such as `conversations`,
/// while still allowing the more general `RatchetStore` trait to stay generic.
pub trait DefaultRatchetStore {
/// Saves the default-domain ratchet state for a conversation.
fn save_default(
&mut self,
conversation_id: &str,
state: &RatchetState<DefaultDomain>,
) -> Result<(), StorageError>;
/// Loads the default-domain ratchet state for a conversation.
fn load_default(
&self,
conversation_id: &str,
) -> Result<RatchetState<DefaultDomain>, StorageError>;
/// Checks whether a ratchet state exists for the conversation.
fn exists_default(&self, conversation_id: &str) -> Result<bool, StorageError>;
/// Deletes the ratchet state and any related skipped keys for the conversation.
fn delete_default(&mut self, conversation_id: &str) -> Result<(), StorageError>;
}
impl<T: RatchetStore + ?Sized> DefaultRatchetStore for T {
fn save_default(
&mut self,
conversation_id: &str,
state: &RatchetState<DefaultDomain>,
) -> Result<(), StorageError> {
self.save(conversation_id, state)
}
fn load_default(
&self,
conversation_id: &str,
) -> Result<RatchetState<DefaultDomain>, StorageError> {
self.load(conversation_id)
}
fn exists_default(&self, conversation_id: &str) -> Result<bool, StorageError> {
self.exists(conversation_id)
}
fn delete_default(&mut self, conversation_id: &str) -> Result<(), StorageError> {
self.delete(conversation_id)
}
}