mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-28 03:59:27 +00:00
Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
325 lines
10 KiB
Rust
325 lines
10 KiB
Rust
use blake2::{
|
|
Blake2b, Blake2bMac, Digest,
|
|
digest::{FixedOutput, consts::U18},
|
|
};
|
|
use chat_proto::logoschat::{
|
|
convos::private_v1::{PrivateV1Frame, private_v1_frame::FrameType},
|
|
encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption},
|
|
};
|
|
use crypto::{PrivateKey, PublicKey, SymmetricKey32};
|
|
use double_ratchets::{Header, InstallationKeyPair, RatchetState, restore_ratchet_state};
|
|
use prost::{Message as _, bytes::Bytes};
|
|
use std::fmt::Debug;
|
|
use storage::{ConversationKind, ConversationMeta, ConversationStore};
|
|
|
|
use crate::{
|
|
DeliveryService,
|
|
conversation::{ChatError, ConversationId, Convo},
|
|
errors::EncryptionError,
|
|
inbox::PRIVATE_V1_INBOX_ADDRESS,
|
|
outcomes::{Content, ConvoOutcome},
|
|
proto,
|
|
service_context::{ExternalServices, ServiceContext},
|
|
types::AddressedEncryptedPayload,
|
|
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 {
|
|
Initiator,
|
|
Responder,
|
|
}
|
|
|
|
impl Role {
|
|
const fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Initiator => "I",
|
|
Self::Responder => "R",
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BaseConvoId([u8; 18]);
|
|
|
|
impl BaseConvoId {
|
|
fn new(key: &SymmetricKey32) -> Self {
|
|
let base = Blake2bMac::<U18>::new_with_salt_and_personal(key.as_bytes(), b"", b"L-PV1-CID")
|
|
.expect("fixed inputs should never fail");
|
|
Self(base.finalize_fixed().into())
|
|
}
|
|
|
|
fn id_for_participant(&self, role: Role) -> String {
|
|
let hash = Blake2b::<U18>::new()
|
|
.chain_update(self.0)
|
|
.chain_update(role.as_str())
|
|
.finalize();
|
|
hex::encode(hash)
|
|
}
|
|
}
|
|
|
|
pub struct PrivateV1Convo {
|
|
local_convo_id: String,
|
|
remote_convo_id: String,
|
|
dr_state: RatchetState,
|
|
}
|
|
|
|
impl PrivateV1Convo {
|
|
/// Reconstructs a PrivateV1Convo from persisted metadata and ratchet state.
|
|
pub fn new<S: ConversationStore + RatchetStore>(
|
|
store: &S,
|
|
local_convo_id: String,
|
|
remote_convo_id: String,
|
|
) -> Result<Self, ChatError> {
|
|
let dr_record = store.load_ratchet_state(&local_convo_id)?;
|
|
let skipped_keys = store.load_skipped_keys(&local_convo_id)?;
|
|
let dr_state: RatchetState = restore_ratchet_state(dr_record, skipped_keys);
|
|
|
|
Ok(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);
|
|
let remote_convo_id = base_convo_id.id_for_participant(Role::Responder);
|
|
|
|
// TODO: Danger - Fix double-ratchets types to Accept SymmetricKey32
|
|
// perhaps update the DH to work with cryptocrate.
|
|
// init_sender doesn't take ownership of the key so a reference can be used.
|
|
let shared_secret: [u8; 32] = seed_key.DANGER_to_bytes();
|
|
let dr_state = RatchetState::init_sender(shared_secret, *remote);
|
|
|
|
Self {
|
|
local_convo_id,
|
|
remote_convo_id,
|
|
dr_state,
|
|
}
|
|
}
|
|
|
|
pub fn new_responder(seed_key: SymmetricKey32, dh_self: &PrivateKey) -> Self {
|
|
let base_convo_id = BaseConvoId::new(&seed_key);
|
|
let local_convo_id = base_convo_id.id_for_participant(Role::Responder);
|
|
let remote_convo_id = base_convo_id.id_for_participant(Role::Initiator);
|
|
|
|
// TODO: (P3) Rename; This accepts a Ephemeral key in most cases
|
|
let dh_self_installation_keypair =
|
|
InstallationKeyPair::from_secret_bytes(dh_self.DANGER_to_bytes());
|
|
// TODO: Danger - Fix double-ratchets types to Accept SymmetricKey32
|
|
let dr_state =
|
|
RatchetState::init_receiver(seed_key.DANGER_to_bytes(), dh_self_installation_keypair);
|
|
|
|
Self {
|
|
local_convo_id,
|
|
remote_convo_id,
|
|
dr_state,
|
|
}
|
|
}
|
|
|
|
fn encrypt(&mut self, frame: PrivateV1Frame) -> EncryptedPayload {
|
|
let encoded_bytes = frame.encode_to_vec();
|
|
let (cipher_text, header) = self.dr_state.encrypt_message(&encoded_bytes);
|
|
|
|
EncryptedPayload {
|
|
encryption: Some(Encryption::Doubleratchet(Doubleratchet {
|
|
dh: Bytes::from(Vec::from(header.dh_pub.to_bytes())),
|
|
msg_num: header.msg_num,
|
|
prev_chain_len: header.prev_chain_len,
|
|
ciphertext: Bytes::from(cipher_text),
|
|
aux: "".into(),
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn decrypt(&mut self, payload: EncryptedPayload) -> Result<PrivateV1Frame, EncryptionError> {
|
|
// Validate and extract the encryption header or return errors
|
|
let dr_header = if let Some(enc) = payload.encryption {
|
|
if let proto::Encryption::Doubleratchet(dr) = enc {
|
|
dr
|
|
} else {
|
|
return Err(EncryptionError::Decryption(
|
|
"incorrect encryption type".into(),
|
|
));
|
|
}
|
|
} else {
|
|
return Err(EncryptionError::Decryption("missing payload".into()));
|
|
};
|
|
|
|
// Turn the bytes into a PublicKey
|
|
let byte_arr: [u8; 32] = dr_header
|
|
.dh
|
|
.to_vec()
|
|
.try_into()
|
|
.map_err(|_| EncryptionError::Decryption("invalid public key length".into()))?;
|
|
let dh_pub = PublicKey::from(byte_arr);
|
|
|
|
// Build the Header that DR impl expects
|
|
let header = Header {
|
|
dh_pub: *dh_pub,
|
|
msg_num: dr_header.msg_num,
|
|
prev_chain_len: dr_header.prev_chain_len,
|
|
};
|
|
|
|
// Decrypt into Frame
|
|
let content_bytes = self
|
|
.dr_state
|
|
.decrypt_message(&dr_header.ciphertext, header)
|
|
.map_err(|e| EncryptionError::Decryption(e.to_string()))?;
|
|
Ok(PrivateV1Frame::decode(content_bytes.as_slice()).unwrap())
|
|
}
|
|
|
|
/// Persists a conversation's metadata and ratchet state to DB.
|
|
pub fn persist<S: ConversationStore + RatchetStore>(
|
|
&mut self,
|
|
store: &mut S,
|
|
) -> Result<ConversationId, ChatError> {
|
|
let convo_info = ConversationMeta {
|
|
local_convo_id: self.id().to_string(),
|
|
remote_convo_id: self.remote_id(),
|
|
kind: self.convo_type(),
|
|
};
|
|
store.save_conversation(&convo_info)?;
|
|
self.save_ratchet_state(store)?;
|
|
Ok(self.id().to_string())
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
fn handle_content(&self, bytes: Bytes) -> Content {
|
|
Content {
|
|
bytes: bytes.into(),
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> &str {
|
|
&self.local_convo_id
|
|
}
|
|
|
|
pub fn encrypt_content<S: RatchetStore>(
|
|
&mut self,
|
|
content: &[u8],
|
|
store: &mut S,
|
|
) -> Result<Vec<AddressedEncryptedPayload>, ChatError> {
|
|
let frame = PrivateV1Frame {
|
|
conversation_id: self.id().into(),
|
|
sender: "delete".into(),
|
|
timestamp: timestamp_millis(),
|
|
frame_type: Some(FrameType::Content(content.to_vec().into())),
|
|
};
|
|
|
|
let data = self.encrypt(frame);
|
|
|
|
self.save_ratchet_state(store)?;
|
|
|
|
Ok(vec![AddressedEncryptedPayload {
|
|
delivery_address: PRIVATE_V1_INBOX_ADDRESS.into(),
|
|
data,
|
|
}])
|
|
}
|
|
|
|
pub fn remote_id(&self) -> String {
|
|
self.remote_convo_id.clone()
|
|
}
|
|
|
|
pub fn convo_type(&self) -> ConversationKind {
|
|
ConversationKind::PrivateV1
|
|
}
|
|
}
|
|
|
|
impl<S: ExternalServices> Convo<S> for PrivateV1Convo {
|
|
fn send_content(
|
|
&mut self,
|
|
cx: &mut ServiceContext<S>,
|
|
content: &[u8],
|
|
) -> Result<(), ChatError> {
|
|
let payloads = self.encrypt_content(content, &mut cx.store)?;
|
|
let remote_id = self.remote_id();
|
|
for payload in payloads {
|
|
cx.ds
|
|
.publish(payload.into_envelope(remote_id.clone()))
|
|
.map_err(|e| ChatError::Delivery(e.to_string()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_frame(
|
|
&mut self,
|
|
cx: &mut ServiceContext<S>,
|
|
enc: EncryptedPayload,
|
|
) -> Result<ConvoOutcome, ChatError> {
|
|
let frame = self
|
|
.decrypt(enc)
|
|
.map_err(|_| ChatError::Protocol("decryption".into()))?;
|
|
|
|
let Some(frame_type) = frame.frame_type else {
|
|
return Err(ChatError::ProtocolExpectation("None", "Some".into()));
|
|
};
|
|
|
|
self.save_ratchet_state(&mut cx.store)?;
|
|
|
|
let content = match frame_type {
|
|
FrameType::Content(bytes) => Some(self.handle_content(bytes)),
|
|
FrameType::Placeholder(_) => None,
|
|
};
|
|
Ok(ConvoOutcome {
|
|
convo_id: self.id().to_string(),
|
|
content,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Debug for PrivateV1Convo {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("PrivateV1Convo")
|
|
.field("dr_state", &"******")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crypto::PrivateKey;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_encrypt_roundtrip() {
|
|
let saro = PrivateKey::random();
|
|
let raya = PrivateKey::random();
|
|
|
|
let pub_raya = PublicKey::from(&raya);
|
|
|
|
let seed_key = saro.diffie_hellman(&pub_raya).DANGER_to_bytes();
|
|
let seed_key_saro = SymmetricKey32::from(seed_key);
|
|
let seed_key_raya = SymmetricKey32::from(seed_key);
|
|
let send_content_bytes = vec![0, 2, 4, 6, 8];
|
|
let mut sr_convo = PrivateV1Convo::new_initiator(seed_key_saro, pub_raya);
|
|
let mut rs_convo = PrivateV1Convo::new_responder(seed_key_raya, &raya);
|
|
|
|
let send_frame = PrivateV1Frame {
|
|
conversation_id: "_".into(),
|
|
sender: Bytes::new(),
|
|
timestamp: timestamp_millis(),
|
|
frame_type: Some(FrameType::Content(Bytes::from(send_content_bytes.clone()))),
|
|
};
|
|
let payload = sr_convo.encrypt(send_frame.clone());
|
|
let recv_frame = rs_convo.decrypt(payload).unwrap();
|
|
|
|
assert!(
|
|
recv_frame == send_frame,
|
|
"{:?}. {:?}",
|
|
recv_frame,
|
|
send_content_bytes
|
|
);
|
|
}
|
|
}
|