From 3b69f946fd6cc0dd3e62818a17064ec0fd023899 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:10:21 -0800 Subject: [PATCH] PrivateV1 Convo Ids (#54) * Add conversation_ids for privateV1 * Skip handling of unknown payloads * Tag initial ContentData as new * Add Integration test * truncate convo_id to size * Clippy fixes * cleanup * Apply suggestion from @osmaczko Co-authored-by: osmaczko <33099791+osmaczko@users.noreply.github.com> * Apply suggestion from @osmaczko Co-authored-by: osmaczko <33099791+osmaczko@users.noreply.github.com> * Linter fixes --------- Co-authored-by: osmaczko <33099791+osmaczko@users.noreply.github.com> --- conversations/src/context.rs | 52 ++++++++++++++- conversations/src/conversation/privatev1.rs | 72 ++++++++++++++++++--- conversations/src/inbox/handler.rs | 13 +++- 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 8eedbd4..3e16386 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -79,7 +79,7 @@ impl Context { 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), - _ => Err(ChatError::NoConvo(convo_id)), + _ => Ok(None), } } @@ -137,4 +137,54 @@ mod tests { let convo = store.get_mut(&convo_id).ok_or(0); convo.unwrap(); } + + fn send_and_verify( + sender: &mut Context, + receiver: &mut Context, + convo_id: ConversationId, + content: &[u8], + ) { + let payloads = sender.send_content(convo_id, content).unwrap(); + let payload = payloads.first().unwrap(); + let received = receiver + .handle_payload(&payload.data) + .unwrap() + .expect("expected content"); + assert_eq!(content, received.data.as_slice()); + assert!(!received.is_new_convo); // Check that `is_new_convo` is FALSE + } + + #[test] + fn ctx_integration() { + let mut saro = Context::new(); + let mut raya = Context::new(); + + // Raya creates intro bundle and sends to Saro + let bundle = raya.create_intro_bundle().unwrap(); + let intro = Introduction::try_from(bundle.as_slice()).unwrap(); + + // Saro initiates conversation with Raya + let mut content = vec![10]; + let (saro_convo_id, payloads) = saro.create_private_convo(&intro, &content); + + // Raya receives initial message + let payload = payloads.first().unwrap(); + let initial_content = raya + .handle_payload(&payload.data) + .unwrap() + .expect("expected initial content"); + + let raya_convo_id = initial_content.conversation_id; + assert_eq!(content, initial_content.data); + assert!(initial_content.is_new_convo); + + // Exchange messages back and forth + for _ in 0..10 { + content.push(content.last().unwrap() + 1); + send_and_verify(&mut raya, &mut saro, &raya_convo_id, &content); + + content.push(content.last().unwrap() + 1); + send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content); + } + } } diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index 07af790..e48c27c 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -1,3 +1,7 @@ +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}, @@ -16,25 +20,79 @@ use crate::{ utils::timestamp_millis, }; +// 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: &SecretKey) -> Self { + let base = Blake2bMac::::new_with_salt_and_personal(key.as_slice(), 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::::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 { pub fn new_initiator(seed_key: SecretKey, 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 SecretKey // 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.as_bytes().to_vec().try_into().unwrap(); + let dr_state = RatchetState::init_sender(shared_secret, remote); + Self { - dr_state: RatchetState::init_sender(shared_secret, remote), + local_convo_id, + remote_convo_id, + dr_state, } } - pub fn new_responder(seed_key: SecretKey, dh_self: InstallationKeyPair) -> Self { + pub fn new_responder( + seed_key: SecretKey, + dh_self: InstallationKeyPair, // TODO: (P3) Rename; This accepts a Ephemeral key in most cases + ) -> 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: Danger - Fix double-ratchets types to Accept SecretKey + let dr_state = RatchetState::init_receiver(seed_key.as_bytes().to_owned(), dh_self); + Self { - // TODO: Danger - Fix double-ratchets types to Accept SecretKey - dr_state: RatchetState::init_receiver(seed_key.as_bytes().to_owned(), dh_self), + local_convo_id, + remote_convo_id, + dr_state, } } @@ -102,8 +160,7 @@ impl PrivateV1Convo { impl Id for PrivateV1Convo { fn id(&self) -> ConversationId<'_> { - // TODO: implementation - "private_v1_convo_id" + &self.local_convo_id } } @@ -150,8 +207,7 @@ impl Convo for PrivateV1Convo { } fn remote_id(&self) -> String { - //TODO: Implement as per spec - self.id().into() + self.remote_convo_id.clone() } } diff --git a/conversations/src/inbox/handler.rs b/conversations/src/inbox/handler.rs index 289ce1e..a2fc472 100644 --- a/conversations/src/inbox/handler.rs +++ b/conversations/src/inbox/handler.rs @@ -137,12 +137,19 @@ impl Inbox { PrivateV1Convo::new_responder(seed_key, ephemeral_key.clone().into()); let Some(enc_payload) = _invite_private_v1.initial_message else { - return Err(ChatError::Protocol("Invite: missing initial".into())); + return Err(ChatError::Protocol("missing initial encpayload".into())); }; - let content = convo.handle_frame(enc_payload)?; + // Set is_new_convo for content data + let content = match convo.handle_frame(enc_payload)? { + Some(v) => ContentData { + is_new_convo: true, + ..v + }, + None => return Err(ChatError::Protocol("expected contentData".into())), + }; - Ok((Box::new(convo), content)) + Ok((Box::new(convo), Some(content))) } } }