diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index e550ae5..30b9e24 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -241,8 +241,8 @@ where match command { "/help" => { self.add_system_message("── Commands ──"); - self.add_system_message("/intro - Show your introduction bundle"); - self.add_system_message("/connect - Connect using a bundle"); + self.add_system_message("/intro - Show your address"); + self.add_system_message("/connect
- Connect using an address"); self.add_system_message("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); self.add_system_message("/switch - Switch active chat"); @@ -253,30 +253,28 @@ where Ok(Some("Help displayed".to_string())) } "/intro" => { - let bundle_bytes = self - .client - .create_intro_bundle() - .map_err(|e| anyhow::anyhow!("{e:?}"))?; - let bundle_str = String::from_utf8_lossy(&bundle_bytes).to_string(); - self.add_system_message("── Your Introduction Bundle ──"); - self.add_system_message(&bundle_str); - let clipboard_msg = match Clipboard::new() - .and_then(|mut cb| cb.set_text(&bundle_str)) + let address = self.client.addr().to_string(); + self.add_system_message("── Your Address ──"); + self.add_system_message(&address); + let clipboard_msg = match Clipboard::new().and_then(|mut cb| cb.set_text(&address)) { - Ok(()) => "Bundle copied to clipboard! Share it, then /connect their bundle.", - Err(_) => "Share this bundle with others to connect!", + Ok(()) => "Address copied to clipboard! Share it, then /connect their address.", + Err(_) => "Share this address with others to connect!", }; self.add_system_message(clipboard_msg); - Ok(Some("Bundle created".to_string())) + Ok(Some("Address shown".to_string())) } "/connect" => { if args.is_empty() { - return Ok(Some("Usage: /connect ".to_string())); + return Ok(Some("Usage: /connect
".to_string())); } let initial = format!("Hello from {}!", self.user_name); let chat_id = self .client - .create_conversation(args.as_bytes(), initial.as_bytes()) + .create_direct_conversation(args) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + self.client + .send_message(&chat_id, initial.as_bytes()) .map_err(|e| anyhow::anyhow!("{e:?}"))?; let label = chat_id[..8.min(chat_id.len())].to_string(); diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index 34e568c..3581eba 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -2,7 +2,6 @@ mod direct_v1; pub mod group_v1; mod group_v2; pub mod mls_extensions; -mod privatev1; pub use crate::errors::ChatError; use crate::outcomes::ConvoOutcome; @@ -12,7 +11,6 @@ use crate::types::ConvoMetadata; pub use direct_v1::DirectV1Convo; pub use group_v1::GroupV1Convo; pub use group_v2::{GroupV2Clock, GroupV2Convo}; -pub use privatev1::PrivateV1Convo; use shared_traits::IdentIdRef; pub type ConversationId = String; diff --git a/core/conversations/src/conversation/privatev1.rs b/core/conversations/src/conversation/privatev1.rs deleted file mode 100644 index e8dea97..0000000 --- a/core/conversations/src/conversation/privatev1.rs +++ /dev/null @@ -1,332 +0,0 @@ -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, ConversationIdRef, Convo, Identified}, - 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::::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::::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( - store: &S, - local_convo_id: String, - remote_convo_id: String, - ) -> Result { - 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 { - // 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( - &mut self, - store: &mut S, - ) -> Result { - 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(&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(), - encoded_credential: vec![], - } - } - - pub fn encrypt_content( - &mut self, - content: &[u8], - store: &mut S, - ) -> Result, 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 Identified for PrivateV1Convo { - fn id(&self) -> ConversationIdRef<'_> { - &self.local_convo_id - } -} - -impl Convo for PrivateV1Convo { - fn send_content( - &mut self, - cx: &mut ServiceContext, - 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, - enc: EncryptedPayload, - ) -> Result { - 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, - members_changed: false, - }) - } - - fn wakeup(&mut self, _: &mut ServiceContext) -> Result { - Ok(ConvoOutcome::empty(self.id().to_string())) - } -} - -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 - ); - } -} diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 414e703..99ad312 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -1,6 +1,6 @@ use crate::causal_history::{CausalHistoryStore, MissingMessage}; use crate::conversation::{ - ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo, + ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, }; use crate::service_context::{ExternalServices, ServiceContext}; use crate::types::ConvoMetadata; @@ -11,7 +11,6 @@ use crate::{ use crate::{ conversation::{Convo, GroupConvo}, errors::ChatError, - inbox::Inbox, inbox_v2::{InboxV2, MlsEphemeralPqProvider, MlsIdentityProvider}, outcomes::{ConvoOutcome, InboxOutcome, PayloadOutcome}, proto::{EncryptedPayload, EnvelopeV1, Message}, @@ -25,7 +24,6 @@ use storage::{ChatStore, ConversationKind, ConversationStore}; use tracing::{info, instrument}; pub use crate::conversation::ConversationId; -pub use crate::inbox::Introduction; // This is the main entry point to the conversations api. // `Core` manages lifetimes of objects to process and generate payloads. @@ -35,7 +33,6 @@ pub use crate::inbox::Introduction; // primitives with plain `&mut self`. pub struct Core { services: ServiceContext, - inbox: Inbox, pq_inbox: InboxV2, // Cache of loaded conversations cached_convos: HashMap>, @@ -125,7 +122,6 @@ where wakeup_service: WS, store: CS, ) -> Result { - let inbox = Inbox::new(&identity); // InboxV2 rendezvous is signer-scoped: it subscribes under the hex of // the signer's verifying key — the same string the account → device // directory lists and the registries key key-packages under, so it is @@ -137,10 +133,7 @@ where let causal = CausalHistoryStore::new(); let pq_inbox = InboxV2::new(ident_id); - // Subscribe to inbound addresses for both conversation stacks. - delivery - .subscribe(inbox.delivery_address()) - .map_err(ChatError::generic)?; + // Subscribe to the InboxV2 rendezvous address. delivery .subscribe(&pq_inbox.delivery_address()) .map_err(ChatError::generic)?; @@ -158,7 +151,6 @@ where demls_clock: GroupV2Clock::default(), demls_config: GroupV2Config::default(), }, - inbox, pq_inbox, cached_convos: HashMap::new(), }) @@ -206,26 +198,6 @@ impl<'a, S: ExternalServices + 'static> Core { self.create_direct_convo_v1(members) } - pub fn create_private_convo_v1( - &mut self, - remote_bundle: &Introduction, - content: &[u8], - ) -> Result { - let (mut convo, payloads) = - self.inbox - .invite_to_private_convo(&mut self.services, remote_bundle, content)?; - - let remote_id = Inbox::inbox_identifier_for_key(*remote_bundle.installation_key()); - let convo_id = convo.persist(&mut self.services.store)?; - for payload in payloads { - self.services - .ds - .publish(payload.into_envelope(remote_id.clone())) - .map_err(|e| ChatError::Delivery(e.to_string()))?; - } - Ok(convo_id) - } - pub fn create_direct_convo_v1( &mut self, members: &[IdentIdRef], @@ -382,7 +354,6 @@ impl<'a, S: ExternalServices + 'static> Core { let convo_id = env.conversation_hint; match convo_id { - c if c == self.inbox.id() => self.dispatch_to_inbox(&env.payload).map(Into::into), c if c == self.pq_inbox.id() => self.dispatch_to_inbox2(&env.payload), c if self.cached_convos.contains_key(&c) => { self.dispatch_to_convo(&c, &env.payload).map(Into::into) @@ -394,17 +365,6 @@ impl<'a, S: ExternalServices + 'static> Core { } } - // Dispatch encrypted payload to Inbox. The Inbox persists the newly - // created conversation and consumes the ephemeral key internally. - fn dispatch_to_inbox(&mut self, enc_payload_bytes: &[u8]) -> Result { - // EncryptedPayloads are not used by GroupConvos at this time, else this can be performed in `handle_payload` - // TODO: (P1) reconcile envelope parsing between Covno and GroupConvo - let enc_payload = EncryptedPayload::decode(enc_payload_bytes)?; - let public_key_hex = Inbox::extract_ephemeral_key_hex(&enc_payload)?; - self.inbox - .handle_frame(&mut self.services, enc_payload, &public_key_hex) - } - // Dispatch encrypted payload to the post-quantum inbox. fn dispatch_to_inbox2(&mut self, payload: &[u8]) -> Result { if let Some((convo, class)) = self.pq_inbox.handle_frame(&mut self.services, payload)? { @@ -479,11 +439,6 @@ impl<'a, S: ExternalServices + 'static> Core { fn load_convo(&mut self, convo_id: &str) -> Result>, ChatError> { let record = self.load_conversation_meta(convo_id)?; Ok(match record.kind { - ConversationKind::PrivateV1 => Box::new(PrivateV1Convo::new( - &self.services.store, - record.local_convo_id, - record.remote_convo_id, - )?), ConversationKind::GroupV1 => Box::new(self.load_mls_convo(&record.local_convo_id)?), ConversationKind::Unknown(_) => { return Err(ChatError::UnsupportedConvoType(record.kind.as_str().into())); @@ -496,9 +451,6 @@ impl<'a, S: ExternalServices + 'static> Core { let record = self.load_conversation_meta(convo_id)?; match record.kind { ConversationKind::GroupV1 => Ok(Box::new(self.load_mls_convo(&record.local_convo_id)?)), - ConversationKind::PrivateV1 => { - Err(ChatError::NoConvo("this is not a group convo".into())) - } ConversationKind::Unknown(_) => { Err(ChatError::UnsupportedConvoType(record.kind.as_str().into())) } @@ -512,11 +464,6 @@ impl<'a, S: ExternalServices + 'static> Core { GroupV1Convo::load(&mut self.services, convo_id.to_string(), group_id) } - pub fn create_intro_bundle(&mut self) -> Result, ChatError> { - let intro = self.inbox.create_intro_bundle(&mut self.services)?; - Ok(intro.into()) - } - /// Loads a conversation's metadata from storage. fn load_conversation_meta( &self, diff --git a/core/conversations/src/crypto.rs b/core/conversations/src/crypto.rs deleted file mode 100644 index 148529b..0000000 --- a/core/conversations/src/crypto.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub use crypto::{PrivateKey, PublicKey}; -use prost::bytes::Bytes; - -pub trait CopyBytes { - fn copy_to_bytes(&self) -> Bytes; -} - -impl CopyBytes for PublicKey { - fn copy_to_bytes(&self) -> Bytes { - Bytes::copy_from_slice(self.as_bytes()) - } -} diff --git a/core/conversations/src/errors.rs b/core/conversations/src/errors.rs index 40aedde..225c469 100644 --- a/core/conversations/src/errors.rs +++ b/core/conversations/src/errors.rs @@ -55,9 +55,3 @@ impl ChatError { Self::Generic(e.to_string()) } } - -#[derive(Error, Debug)] -pub enum EncryptionError { - #[error("decryption: {0}")] - Decryption(String), -} diff --git a/core/conversations/src/inbox.rs b/core/conversations/src/inbox.rs deleted file mode 100644 index 1ea4073..0000000 --- a/core/conversations/src/inbox.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod handler; -mod handshake; -mod introduction; - -pub use handler::{Inbox, PRIVATE_V1_INBOX_ADDRESS}; -pub use introduction::Introduction; diff --git a/core/conversations/src/inbox/handler.rs b/core/conversations/src/inbox/handler.rs deleted file mode 100644 index ccbfff7..0000000 --- a/core/conversations/src/inbox/handler.rs +++ /dev/null @@ -1,345 +0,0 @@ -use blake2::{Blake2b512, Digest}; -use chat_proto::logoschat::encryption::EncryptedPayload; -use prost::Message; -use prost::bytes::Bytes; -use rand_core::OsRng; -use storage::EphemeralKeyStore; - -use crypto::{PrekeyBundle, SymmetricKey32}; - -use crate::conversation::{ChatError, Convo, PrivateV1Convo}; -use crate::crypto::{CopyBytes, PrivateKey, PublicKey}; -use crate::inbox::Introduction; -use crate::inbox::handshake::InboxHandshake; -use crate::outcomes::{ConversationClass, InboxOutcome, NewConversation}; -use crate::proto; -use crate::service_context::{ExternalServices, ServiceContext}; -use crate::types::AddressedEncryptedPayload; -use crypto::Identity; - -/// Transport address shared by all PrivateV1 inbox traffic. -pub const PRIVATE_V1_INBOX_ADDRESS: &str = "delivery_address"; - -/// Compute the deterministic Delivery_address for an installation -fn delivery_address_for_installation(_: PublicKey) -> String { - // TODO: Implement Delivery Address - PRIVATE_V1_INBOX_ADDRESS.into() -} - -pub struct Inbox { - local_convo_id: String, -} - -impl std::fmt::Debug for Inbox { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Inbox") - .field("convo_id", &self.local_convo_id) - .finish() - } -} - -impl Inbox { - pub fn new(ident: &Identity) -> Self { - let local_convo_id = Self::inbox_identifier_for_key(ident.public_key()); - Self { local_convo_id } - } - - /// 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, - cx: &mut ServiceContext, - ) -> Result { - let ephemeral = PrivateKey::random(); - - let ephemeral_key: PublicKey = (&ephemeral).into(); - let public_key_hex = hex::encode(ephemeral_key.as_bytes()); - - cx.store.save_ephemeral_key(&public_key_hex, &ephemeral)?; - - let intro = Introduction::new(cx.identity.secret(), ephemeral_key, OsRng); - Ok(intro) - } - - pub fn invite_to_private_convo( - &self, - cx: &mut ServiceContext, - remote_bundle: &Introduction, - initial_message: &[u8], - ) -> Result<(PrivateV1Convo, Vec), ChatError> { - let mut rng = OsRng; - - let pkb = PrekeyBundle { - identity_key: *remote_bundle.installation_key(), - signed_prekey: *remote_bundle.ephemeral_key(), - signature: *remote_bundle.signature(), - onetime_prekey: None, - }; - - let (seed_key, ephemeral_pub) = - InboxHandshake::perform_as_initiator(cx.identity.secret(), &pkb, &mut rng); - - let mut convo = PrivateV1Convo::new_initiator(seed_key, *remote_bundle.ephemeral_key()); - - let mut payloads = convo.encrypt_content(initial_message, &mut cx.store)?; - - // Wrap First payload in Invite - if let Some(first_message) = payloads.get_mut(0) { - // Take the the value of .data - it's being replaced at the end of this block - let frame = Self::wrap_in_invite(std::mem::take(&mut first_message.data)); - - // TODO: Encrypt frame - let ciphertext = frame.encode_to_vec(); - - let header = proto::InboxHeaderV1 { - initiator_static: cx.identity.public_key().copy_to_bytes(), - initiator_ephemeral: ephemeral_pub.copy_to_bytes(), - responder_static: remote_bundle.installation_key().copy_to_bytes(), - responder_ephemeral: remote_bundle.ephemeral_key().copy_to_bytes(), - }; - - let handshake = proto::InboxHandshakeV1 { - header: Some(header), - payload: Bytes::from_owner(ciphertext), - }; - - // Update the address field with the Inbox delivery_Address - first_message.delivery_address = - delivery_address_for_installation(*remote_bundle.installation_key()); - // Update the data field with new Payload - first_message.data = proto::EncryptedPayload { - encryption: Some(proto::Encryption::InboxHandshake(handshake)), - }; - } - - Ok((convo, payloads)) - } - - /// Handles an incoming inbox frame. The caller must provide the ephemeral - /// private key hex looked up from storage. Persists the created - /// conversation and consumes the ephemeral key. Returns the - /// [`InboxOutcome`] describing what was observed — for a successful - /// invite, a `new_conversation` and the initial `ConvoOutcome` carrying - /// the first message. - pub fn handle_frame( - &self, - cx: &mut ServiceContext, - enc_payload: EncryptedPayload, - public_key_hex: &str, - ) -> Result { - let ephemeral_key = cx - .store - .load_ephemeral_key(public_key_hex)? - .ok_or(ChatError::UnknownEphemeralKey())?; - - let handshake = Self::extract_payload(enc_payload)?; - - let header = handshake - .header - .ok_or(ChatError::UnexpectedPayload("InboxV1Header".into()))?; - - // Perform handshake and decrypt frame - let (seed_key, frame) = - self.perform_handshake(&cx.identity, &ephemeral_key, header, handshake.payload)?; - - let result = match frame.frame_type.unwrap() { - proto::inbox_v1_frame::FrameType::InvitePrivateV1(_invite_private_v1) => { - let mut convo = PrivateV1Convo::new_responder(seed_key, &ephemeral_key); - - let Some(enc_payload) = _invite_private_v1.initial_message else { - return Err(ChatError::Protocol("missing initial encpayload".into())); - }; - - let initial = convo.handle_frame(cx, enc_payload)?; - if initial.content.is_none() { - return Err(ChatError::Protocol( - "expected initial message in invite".into(), - )); - } - - let new_conversation = NewConversation { - convo_id: initial.convo_id.clone(), - class: ConversationClass::Private, - }; - convo.persist(&mut cx.store)?; - - InboxOutcome { - new_conversation, - initial: Some(initial), - } - } - }; - - cx.store.remove_ephemeral_key(public_key_hex)?; - - Ok(result) - } - - /// 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 { - 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(), - initial_message: Some(payload), - }; - - proto::InboxV1Frame { - frame_type: Some( - chat_proto::logoschat::inbox::inbox_v1_frame::FrameType::InvitePrivateV1(invite), - ), - } - } - - fn perform_handshake( - &self, - ident: &Identity, - ephemeral_key: &PrivateKey, - header: proto::InboxHeaderV1, - bytes: Bytes, - ) -> Result<(SymmetricKey32, proto::InboxV1Frame), ChatError> { - // Get PublicKeys from protobuf - let initator_static = PublicKey::from( - <[u8; 32]>::try_from(header.initiator_static.as_ref()) - .map_err(|_| ChatError::BadBundleValue("wrong size - initator static".into()))?, - ); - - let initator_ephemeral = PublicKey::from( - <[u8; 32]>::try_from(header.initiator_ephemeral.as_ref()) - .map_err(|_| ChatError::BadBundleValue("wrong size - initator ephemeral".into()))?, - ); - - let seed_key = InboxHandshake::perform_as_responder( - ident.secret(), - ephemeral_key, - None, - &initator_static, - &initator_ephemeral, - ); - - // TODO: Decrypt Content - let frame = self.decrypt_frame(bytes)?; - Ok((seed_key, frame)) - } - - fn extract_payload( - payload: proto::EncryptedPayload, - ) -> Result { - let Some(proto::Encryption::InboxHandshake(handshake)) = payload.encryption else { - let got = format!("{:?}", payload.encryption); - - return Err(ChatError::ProtocolExpectation("inboxhandshake", got)); - }; - - Ok(handshake) - } - - fn decrypt_frame(&self, enc_frame_bytes: Bytes) -> Result { - // TODO: decrypt payload - let frame = proto::InboxV1Frame::decode(enc_frame_bytes)?; - Ok(frame) - } - - pub fn inbox_identifier_for_key(pubkey: PublicKey) -> String { - // TODO: Implement ID according to spec - hex::encode(Blake2b512::digest(pubkey)) - } - - pub fn id(&self) -> &str { - &self.local_convo_id - } - - /// Transport address this inbox receives PrivateV1 traffic on. - pub fn delivery_address(&self) -> &str { - PRIVATE_V1_INBOX_ADDRESS - } -} - -#[cfg(test)] -mod tests { - - use super::*; - use chat_sqlite::{ChatStorage, StorageConfig}; - use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; - use shared_traits::{IdentId, IdentityProvider}; - - struct Identity { - name: IdentId, - key: Ed25519SigningKey, - verify: Ed25519VerifyingKey, - } - - impl Identity { - pub fn new(name: impl Into) -> Self { - let key = Ed25519SigningKey::generate(); - let verify = key.verifying_key(); - Identity { - name: IdentId::new(name.into()), - key, - verify, - } - } - } - - impl IdentityProvider for Identity { - fn id(&self) -> shared_traits::IdentIdRef<'_> { - &self.name - } - - fn display_name(&self) -> String { - self.name.to_string() - } - - fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature { - self.key.sign(payload) - } - - fn public_key(&self) -> &crypto::Ed25519VerifyingKey { - &self.verify - } - } - - #[test] - fn test_invite_privatev1_roundtrip() { - let saro_storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); - let raya_storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); - - let saro_account = Identity::new("saro"); - let raya_account = Identity::new("raya"); - - let mut saro_cx = ServiceContext::for_test(saro_account, saro_storage).unwrap(); - let saro_inbox = Inbox::new(&saro_cx.identity); - - let mut raya_cx = ServiceContext::for_test(raya_account, raya_storage).unwrap(); - let raya_inbox = Inbox::new(&raya_cx.identity); - - let bundle = raya_inbox.create_intro_bundle(&mut raya_cx).unwrap(); - - let (_, mut payloads) = saro_inbox - .invite_to_private_convo(&mut saro_cx, &bundle, "hello".as_bytes()) - .unwrap(); - - let payload = payloads.remove(0); - let key_hex = Inbox::extract_ephemeral_key_hex(&payload.data).unwrap(); - - let result = raya_inbox.handle_frame(&mut raya_cx, payload.data, &key_hex); - - assert!( - result.is_ok(), - "handle_frame should accept valid encrypted payloads" - ); - } -} diff --git a/core/conversations/src/inbox/handshake.rs b/core/conversations/src/inbox/handshake.rs deleted file mode 100644 index b92667d..0000000 --- a/core/conversations/src/inbox/handshake.rs +++ /dev/null @@ -1,120 +0,0 @@ -use blake2::{ - Blake2bMac, - digest::{FixedOutput, consts::U32}, -}; -use crypto::{DomainSeparator, PrekeyBundle, SymmetricKey32, X3Handshake}; -use rand_core::{CryptoRng, RngCore}; - -use crate::crypto::{PrivateKey, PublicKey}; - -type Blake2bMac256 = Blake2bMac; - -pub struct InboxDomain; -impl DomainSeparator for InboxDomain { - const BYTES: &'static [u8] = b"logos_chat_inbox"; -} - -type InboxKeyExchange = X3Handshake; - -pub struct InboxHandshake {} - -impl InboxHandshake { - /// Performs - pub fn perform_as_initiator( - identity_keypair: &PrivateKey, - recipient_bundle: &PrekeyBundle, - rng: &mut R, - ) -> (SymmetricKey32, PublicKey) { - // Perform X3DH handshake to get shared secret - let (shared_secret, ephemeral_public) = - InboxKeyExchange::initator(identity_keypair, recipient_bundle, rng); - - let seed_key = Self::derive_keys_from_shared_secret(shared_secret); - (seed_key, ephemeral_public) - } - - /// Perform the Inbox Handshake after receiving a keyBundle - /// - /// # Arguments - /// * `identity_keypair` - Your long-term identity key pair - /// * `signed_prekey` - Your signed prekey (private) - /// * `onetime_prekey` - Your one-time prekey (private, if used) - /// * `initiator_identity` - Initiator's identity public key - /// * `initiator_ephemeral` - Initiator's ephemeral public key - pub fn perform_as_responder( - identity_keypair: &PrivateKey, - signed_prekey: &PrivateKey, - onetime_prekey: Option<&PrivateKey>, - initiator_identity: &PublicKey, - initiator_ephemeral: &PublicKey, - ) -> SymmetricKey32 { - // Perform X3DH to get shared secret - let shared_secret = InboxKeyExchange::responder( - identity_keypair, - signed_prekey, - onetime_prekey, - initiator_identity, - initiator_ephemeral, - ); - - Self::derive_keys_from_shared_secret(shared_secret) - } - - /// Derive keys from X3DH shared secret - fn derive_keys_from_shared_secret(shared_secret: SymmetricKey32) -> SymmetricKey32 { - let seed_key: [u8; 32] = Blake2bMac256::new_with_salt_and_personal( - shared_secret.as_bytes(), - &[], // No salt - input already has high entropy - b"InboxV1-Seed", - ) - .unwrap() - .finalize_fixed() - .into(); // digest uses an incompatible version of GenericArray. use array as intermediary - - seed_key.into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rand_core::OsRng; - - #[test] - fn test_inbox_encryption_initialization() { - let mut rng = OsRng; - - // Alice (initiator) generates her identity key - let alice_identity = PrivateKey::random_from_rng(rng); - let alice_identity_pub = PublicKey::from(&alice_identity); - - // Bob (responder) generates his keys - let bob_identity = PrivateKey::random_from_rng(rng); - let bob_signed_prekey = PrivateKey::random_from_rng(rng); - let bob_signed_prekey_pub = PublicKey::from(&bob_signed_prekey); - - // Create Bob's prekey bundle - let bob_bundle = PrekeyBundle { - identity_key: PublicKey::from(&bob_identity), - signed_prekey: bob_signed_prekey_pub, - signature: crypto::XedDsaSignature([0u8; 64]), - onetime_prekey: None, - }; - - // Alice performs handshake - let (alice_secret, alice_ephemeral_pub) = - InboxHandshake::perform_as_initiator(&alice_identity, &bob_bundle, &mut rng); - - // Bob performs handshake - let bob_secret = InboxHandshake::perform_as_responder( - &bob_identity, - &bob_signed_prekey, - None, - &alice_identity_pub, - &alice_ephemeral_pub, - ); - - // Both should derive the same root key - assert_eq!(alice_secret, bob_secret); - } -} diff --git a/core/conversations/src/inbox/introduction.rs b/core/conversations/src/inbox/introduction.rs deleted file mode 100644 index d326e4e..0000000 --- a/core/conversations/src/inbox/introduction.rs +++ /dev/null @@ -1,195 +0,0 @@ -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use chat_proto::logoschat::intro::IntroBundle; -use crypto::{PrivateKey, PublicKey, XedDsaSignature}; -use prost::Message; -use rand_core::{CryptoRng, RngCore}; - -use crate::errors::ChatError; - -const BUNDLE_PREFIX: &str = "logos_chatintro_1_"; - -fn intro_binding_message(ephemeral: &PublicKey) -> Vec { - let mut message = Vec::with_capacity(BUNDLE_PREFIX.len() + 32); - message.extend_from_slice(BUNDLE_PREFIX.as_bytes()); - message.extend_from_slice(ephemeral.as_bytes()); - message -} - -pub(crate) fn sign_intro_binding( - secret: &PrivateKey, - ephemeral: &PublicKey, - rng: R, -) -> XedDsaSignature { - let message = intro_binding_message(ephemeral); - crypto::xeddsa_sign(secret, &message, rng) -} - -pub(crate) fn verify_intro_binding( - pubkey: &PublicKey, - ephemeral: &PublicKey, - signature: &XedDsaSignature, -) -> Result<(), crypto::SignatureError> { - let message = intro_binding_message(ephemeral); - crypto::xeddsa_verify(pubkey, &message, signature) -} - -/// Supplies remote participants with the required keys to use Inbox protocol -pub struct Introduction { - installation_key: PublicKey, - ephemeral_key: PublicKey, - signature: XedDsaSignature, -} - -impl Introduction { - /// Create a new `Introduction` by signing the ephemeral key with the installation secret. - pub(crate) fn new( - installation_secret: &PrivateKey, - ephemeral_key: PublicKey, - rng: R, - ) -> Self { - let installation_key = installation_secret.into(); - let signature = sign_intro_binding(installation_secret, &ephemeral_key, rng); - Self { - installation_key, - ephemeral_key, - signature, - } - } - - pub fn installation_key(&self) -> &PublicKey { - &self.installation_key - } - - pub fn ephemeral_key(&self) -> &PublicKey { - &self.ephemeral_key - } - - pub fn signature(&self) -> &XedDsaSignature { - &self.signature - } -} - -impl From for Vec { - fn from(intro: Introduction) -> Vec { - let bundle = IntroBundle { - installation_pubkey: prost::bytes::Bytes::copy_from_slice( - intro.installation_key.as_bytes(), - ), - ephemeral_pubkey: prost::bytes::Bytes::copy_from_slice(intro.ephemeral_key.as_bytes()), - signature: prost::bytes::Bytes::copy_from_slice(intro.signature.as_ref()), - }; - - let base64_encoded = URL_SAFE_NO_PAD.encode(bundle.encode_to_vec()); - - let mut result = String::with_capacity(BUNDLE_PREFIX.len() + base64_encoded.len()); - result.push_str(BUNDLE_PREFIX); - result.push_str(&base64_encoded); - - result.into_bytes() - } -} - -impl TryFrom<&[u8]> for Introduction { - type Error = ChatError; - - fn try_from(value: &[u8]) -> Result { - let str_value = std::str::from_utf8(value) - .map_err(|_| ChatError::BadBundleValue("invalid UTF-8".into()))?; - - let base64_part = str_value.strip_prefix(BUNDLE_PREFIX).ok_or_else(|| { - ChatError::BadBundleValue("not recognized as an introduction bundle".into()) - })?; - - let proto_bytes = URL_SAFE_NO_PAD - .decode(base64_part) - .map_err(|_| ChatError::BadBundleValue("invalid base64".into()))?; - - let bundle = IntroBundle::decode(proto_bytes.as_slice()) - .map_err(|_| ChatError::BadBundleValue("invalid protobuf".into()))?; - - let installation_bytes: [u8; 32] = bundle - .installation_pubkey - .as_ref() - .try_into() - .map_err(|_| ChatError::InvalidKeyLength)?; - - let ephemeral_bytes: [u8; 32] = bundle - .ephemeral_pubkey - .as_ref() - .try_into() - .map_err(|_| ChatError::InvalidKeyLength)?; - - let signature_bytes: [u8; 64] = bundle - .signature - .as_ref() - .try_into() - .map_err(|_| ChatError::BadBundleValue("invalid signature length".into()))?; - - let installation_key = PublicKey::from(installation_bytes); - let ephemeral_key = PublicKey::from(ephemeral_bytes); - let signature = XedDsaSignature::from(signature_bytes); - - verify_intro_binding(&installation_key, &ephemeral_key, &signature) - .map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?; - - Ok(Introduction { - installation_key, - ephemeral_key, - signature, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rand_core::OsRng; - - fn create_test_introduction() -> Introduction { - let install_secret = PrivateKey::random_from_rng(OsRng); - - let ephemeral_secret = PrivateKey::random_from_rng(OsRng); - let ephemeral_pub: PublicKey = (&ephemeral_secret).into(); - - Introduction::new(&install_secret, ephemeral_pub, OsRng) - } - - #[test] - fn test_serialization_roundtrip() { - let intro = create_test_introduction(); - let original_install = *intro.installation_key(); - let original_ephemeral = *intro.ephemeral_key(); - let original_signature = *intro.signature(); - - let encoded: Vec = intro.into(); - let decoded = Introduction::try_from(encoded.as_slice()).unwrap(); - - assert_eq!(*decoded.installation_key(), original_install); - assert_eq!(*decoded.ephemeral_key(), original_ephemeral); - assert_eq!(*decoded.signature(), original_signature); - } - - #[test] - fn test_invalid_prefix_rejected() { - assert!(Introduction::try_from(b"wrong_prefix_AAAA".as_slice()).is_err()); - } - - #[test] - fn test_invalid_base64_rejected() { - assert!(Introduction::try_from(b"logos_chatintro_1_!!!invalid!!!".as_slice()).is_err()); - } - - #[test] - fn test_truncated_payload_rejected() { - let intro = create_test_introduction(); - let encoded: Vec = intro.into(); - let encoded_str = String::from_utf8(encoded).unwrap(); - - let truncated = format!( - "logos_chatintro_1_{}", - &encoded_str[BUNDLE_PREFIX.len()..][..10] - ); - - assert!(Introduction::try_from(truncated.as_bytes()).is_err()); - } -} diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index cde0ff1..ff63d7d 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -161,7 +161,7 @@ impl InboxV2 { convo: &GroupV1Convo, cx: &mut ServiceContext, ) -> Result<(), ChatError> { - // TODO: (P2) Remove remote_convo_id this is an implementation detail specific to PrivateV1 + // TODO: (P2) Remove remote_convo_id: GroupV1 persistence hard-codes it to "0" and nothing reads it back. // TODO: (P3) Implement From for ConversationMeta let meta = ConversationMeta { local_convo_id: convo.id().to_string(), diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index 4cfa0b6..a4b10e6 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -1,9 +1,7 @@ mod causal_history; mod conversation; mod core; -mod crypto; mod errors; -mod inbox; mod inbox_v2; mod outcomes; mod proto; @@ -16,7 +14,7 @@ pub use causal_history::{Frontier, MissingMessage}; pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; pub use conversation::GroupV2Clock; -pub use core::{ConversationId, Core, Introduction}; +pub use core::{ConversationId, Core}; /// Timing/policy for GroupV2 conversations (de-mls's per-conversation config). /// Defaults to the de-mls library defaults; inject via /// [`Core::set_group_v2_config`]. The creator's phase durations (commit diff --git a/core/conversations/src/outcomes.rs b/core/conversations/src/outcomes.rs index afa6e6f..18877e7 100644 --- a/core/conversations/src/outcomes.rs +++ b/core/conversations/src/outcomes.rs @@ -78,7 +78,6 @@ impl ConversationClass { /// `Unknown(_)` yields `None`. pub fn from_kind(kind: &ConversationKind) -> Option { match kind { - ConversationKind::PrivateV1 => Some(Self::Private), ConversationKind::GroupV1 => Some(Self::Group), ConversationKind::Unknown(_) => None, } diff --git a/core/conversations/src/proto.rs b/core/conversations/src/proto.rs index 894ebfe..7d57aa4 100644 --- a/core/conversations/src/proto.rs +++ b/core/conversations/src/proto.rs @@ -1,9 +1,5 @@ -pub use chat_proto::logoschat::encryption::encrypted_payload::Encryption; -pub use chat_proto::logoschat::encryption::inbox_handshake_v1::InboxHeaderV1; -pub use chat_proto::logoschat::encryption::{EncryptedPayload, InboxHandshakeV1}; +pub use chat_proto::logoschat::encryption::EncryptedPayload; pub use chat_proto::logoschat::envelope::EnvelopeV1; -pub use chat_proto::logoschat::inbox::{InboxV1Frame, inbox_v1_frame}; -pub use chat_proto::logoschat::invite::InvitePrivateV1; pub use chat_proto::logoschat::reliability::{HistoryEntry, ReliablePayload}; pub use prost::Message; diff --git a/core/conversations/src/service_context.rs b/core/conversations/src/service_context.rs index c698faf..055d4a5 100644 --- a/core/conversations/src/service_context.rs +++ b/core/conversations/src/service_context.rs @@ -52,74 +52,3 @@ pub(crate) struct ServiceContext { /// welcome's `ConversationSync`. pub(crate) demls_config: de_mls::ConversationConfig, } - -#[cfg(test)] -mod test_support { - use super::*; - use crate::types::AddressedEnvelope; - use crate::{ChatError, IdentityProvider}; - - /// Delivery double that drops every payload. - #[derive(Debug)] - pub(crate) struct NoopDelivery; - - impl DeliveryService for NoopDelivery { - type Error = std::convert::Infallible; - - fn publish(&mut self, _envelope: AddressedEnvelope) -> Result<(), Self::Error> { - Ok(()) - } - - fn subscribe(&mut self, _delivery_address: &str) -> Result<(), Self::Error> { - Ok(()) - } - } - - /// Registration double that holds no key packages. - #[derive(Debug)] - pub(crate) struct NoopRegistration; - - impl RegistrationService for NoopRegistration { - type Error = std::convert::Infallible; - - fn register( - &mut self, - _identity: &dyn IdentityProvider, - _key_bundle: Vec, - ) -> Result<(), Self::Error> { - Ok(()) - } - - fn retrieve(&self, _device_id: &str) -> Result>, Self::Error> { - Ok(None) - } - } - - #[derive(Debug)] - pub(crate) struct NoopWakeups; - - impl WakeupService for NoopWakeups { - fn wakeup_in(&mut self, _: std::time::Duration, _: crate::ConversationId) {} - } - - impl - ServiceContext<(IP, NoopDelivery, NoopRegistration, NoopWakeups, CS)> - { - /// Builds a context around a real store, stubbing other services. - pub(crate) fn for_test(ident: IP, store: CS) -> Result { - let name = ident.id().as_str().to_string(); - Ok(Self { - ds: NoopDelivery, - registry: NoopRegistration, - store, - mls_identity: MlsIdentityProvider::new(ident), - mls_provider: MlsEphemeralPqProvider::new().map_err(ChatError::generic)?, - causal: CausalHistoryStore::new(), - identity: Identity::new(name), - wakeup_service: NoopWakeups {}, - demls_clock: GroupV2Clock::default(), - demls_config: de_mls::ConversationConfig::default(), - }) - } - } -} diff --git a/core/conversations/src/utils.rs b/core/conversations/src/utils.rs index 10958eb..c2a9ea2 100644 --- a/core/conversations/src/utils.rs +++ b/core/conversations/src/utils.rs @@ -1,12 +1,4 @@ use blake2::{Blake2b, Digest}; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub fn timestamp_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64 -} /// Track hash sizes in use across the crate. pub mod hash_size { diff --git a/core/double-ratchets/src/hkdf.rs b/core/double-ratchets/src/hkdf.rs index 82fbfbf..3c9c8d9 100644 --- a/core/double-ratchets/src/hkdf.rs +++ b/core/double-ratchets/src/hkdf.rs @@ -22,14 +22,6 @@ impl HkdfInfo for DefaultDomain { const ROOT_KEY: &'static [u8] = b"DoubleRatchetRootKey"; } -/// Domain for PrivateV1 protocol -#[derive(Clone, Copy)] -pub struct PrivateV1Domain; - -impl HkdfInfo for PrivateV1Domain { - const ROOT_KEY: &'static [u8] = b"PrivateV1RootKey"; -} - /// Spec-level domain separation constants for Double Ratchet chain KDF. /// These are fixed by the Double Ratchet specification and use BLAKE2's /// personalization parameter for domain separation. diff --git a/core/integration_tests_core/tests/private_integration.rs b/core/integration_tests_core/tests/private_integration.rs deleted file mode 100644 index c620f29..0000000 --- a/core/integration_tests_core/tests/private_integration.rs +++ /dev/null @@ -1,278 +0,0 @@ -use chat_sqlite::{ChatStorage, StorageConfig}; -use integration_tests_core::TestIdent; -use libchat::{ConversationClass, Core, Introduction, PayloadOutcome, WakeupService}; -use storage::{ConversationStore, IdentityStore}; -use tempfile::tempdir; - -use components::{EphemeralRegistry, LocalBroadcaster}; - -#[derive(Debug)] -struct NoopWakeupService {} -impl WakeupService for NoopWakeupService { - fn wakeup_in(&mut self, _: std::time::Duration, _: libchat::ConversationId) {} -} - -type PrivateCore = Core<( - TestIdent, - LocalBroadcaster, - EphemeralRegistry, - NoopWakeupService, - ChatStorage, -)>; - -/// Drains everything published to `receiver`'s delivery service and feeds each -/// payload back through `handle_payload`, returning the observed outcomes. -fn deliver(receiver: &mut PrivateCore) -> Vec { - let payloads: Vec<_> = { - let ds = receiver.ds(); - std::iter::from_fn(|| ds.poll()).collect() - }; - payloads - .iter() - .map(|data| receiver.handle_payload(data).unwrap()) - .collect() -} - -/// Delivers to `receiver`, asserting it observed exactly one outcome. -fn recv_one(receiver: &mut PrivateCore) -> PayloadOutcome { - let mut outcomes = deliver(receiver); - assert_eq!( - outcomes.len(), - 1, - "expected exactly one delivered outcome, got {outcomes:?}" - ); - outcomes.pop().unwrap() -} - -fn send_and_verify( - sender: &mut PrivateCore, - receiver: &mut PrivateCore, - convo_id: &str, - content: &[u8], -) { - sender.send_content(convo_id, content).unwrap(); - let content_out = expect_convo(recv_one(receiver)) - .content - .expect("steady-state send should yield one content"); - assert_eq!(content, content_out.bytes.as_slice()); -} - -#[test] -fn ctx_integration() { - let ds = LocalBroadcaster::new(); - let rs = EphemeralRegistry::new(); - - let saro_ident = TestIdent::new("saro"); - let mut saro = Core::new_with_name( - saro_ident, - ds.clone(), - rs.clone(), - NoopWakeupService {}, - ChatStorage::in_memory(), - ) - .unwrap(); - let raya_ident = TestIdent::new("raya"); - let mut raya = Core::new_with_name( - raya_ident, - ds, - rs, - NoopWakeupService {}, - ChatStorage::in_memory(), - ) - .unwrap(); - - // 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 = saro.create_private_convo_v1(&intro, &content).unwrap(); - - // Raya receives the invite + initial message - let initial = recv_one(&mut raya); - let PayloadOutcome::Inbox(io) = initial else { - panic!("invite must yield PayloadOutcome::Inbox, got {initial:?}"); - }; - assert!(matches!( - io.new_conversation.class, - ConversationClass::Private - )); - let initial_co = io.initial.expect("invite must include initial content"); - assert_eq!(io.new_conversation.convo_id, initial_co.convo_id); - let initial_content = initial_co - .content - .expect("invite must include initial message"); - assert_eq!(content, initial_content.bytes); - let raya_convo_id = io.new_conversation.convo_id.clone(); - - // 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); - } -} - -#[test] -fn identity_persistence() { - let ds = LocalBroadcaster::new(); - let rs = EphemeralRegistry::new(); - let store1 = ChatStorage::new(StorageConfig::InMemory).unwrap(); - let alice_ident = TestIdent::new("alice"); - let ctx1 = Core::new_with_name(alice_ident, ds, rs, NoopWakeupService {}, store1).unwrap(); - let pubkey1 = ctx1.identity().public_key(); - let name1 = ctx1.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)); -} - -#[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 ds = LocalBroadcaster::new(); - let rs = EphemeralRegistry::new(); - let store = ChatStorage::new(StorageConfig::File(db_path.clone())).unwrap(); - let alice_ident = TestIdent::new("alice"); - let core = Core::new_from_store(alice_ident, ds, rs, NoopWakeupService {}, store).unwrap(); - let pubkey = core.identity().public_key(); - drop(core); - - 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 ds = LocalBroadcaster::new(); - let rs = EphemeralRegistry::new(); - let alice_ident = TestIdent::new("alice"); - let mut alice = Core::new_with_name( - alice_ident, - ds.clone(), - rs.clone(), - NoopWakeupService {}, - ChatStorage::in_memory(), - ) - .unwrap(); - let bob_ident = TestIdent::new("bob"); - let mut bob = Core::new_with_name( - bob_ident, - ds, - rs, - NoopWakeupService {}, - ChatStorage::in_memory(), - ) - .unwrap(); - - let bundle = alice.create_intro_bundle().unwrap(); - let intro = Introduction::try_from(bundle.as_slice()).unwrap(); - bob.create_private_convo_v1(&intro, b"hi").unwrap(); - - let result = recv_one(&mut alice); - let PayloadOutcome::Inbox(io) = result else { - panic!("invite must yield PayloadOutcome::Inbox, got {result:?}"); - }; - assert!(matches!( - io.new_conversation.class, - ConversationClass::Private - )); - - 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 ds = LocalBroadcaster::new(); - let rs = EphemeralRegistry::new(); - let alice_ident = TestIdent::new("alice"); - let mut alice = Core::new_with_name( - alice_ident, - ds.clone(), - rs.clone(), - NoopWakeupService {}, - ChatStorage::in_memory(), - ) - .unwrap(); - let bob_ident = TestIdent::new("bob"); - let mut bob = Core::new_with_name( - bob_ident, - ds, - rs, - NoopWakeupService {}, - ChatStorage::in_memory(), - ) - .unwrap(); - - let bundle = alice.create_intro_bundle().unwrap(); - let intro = Introduction::try_from(bundle.as_slice()).unwrap(); - let bob_convo_id = bob.create_private_convo_v1(&intro, b"hello").unwrap(); - - let result = recv_one(&mut alice); - let PayloadOutcome::Inbox(io) = result else { - panic!("invite must yield PayloadOutcome::Inbox, got {result:?}"); - }; - let alice_convo_id = io.new_conversation.convo_id.clone(); - - alice.send_content(&alice_convo_id, b"reply 1").unwrap(); - assert_eq!( - expect_convo(recv_one(&mut bob)) - .content - .expect("message content") - .bytes, - b"reply 1" - ); - - bob.send_content(&bob_convo_id, b"reply 2").unwrap(); - assert_eq!( - expect_convo(recv_one(&mut alice)) - .content - .expect("message content") - .bytes, - b"reply 2" - ); - - // Verify conversation list - let convo_ids = alice.list_conversations().unwrap(); - assert_eq!(convo_ids.len(), 1); - - // Continue exchanging messages - bob.send_content(&bob_convo_id, b"more messages").unwrap(); - assert_eq!( - expect_convo(recv_one(&mut alice)) - .content - .expect("message content") - .bytes, - b"more messages" - ); - - // Alice can also send back - alice.send_content(&alice_convo_id, b"alice reply").unwrap(); - assert_eq!( - expect_convo(recv_one(&mut bob)) - .content - .expect("message content") - .bytes, - b"alice reply" - ); -} - -fn expect_convo(result: PayloadOutcome) -> libchat::ConvoOutcome { - match result { - PayloadOutcome::Convo(co) => co, - other => panic!("expected PayloadOutcome::Convo, got {other:?}"), - } -} diff --git a/core/sqlite/src/lib.rs b/core/sqlite/src/lib.rs index 8c57bb3..b021965 100644 --- a/core/sqlite/src/lib.rs +++ b/core/sqlite/src/lib.rs @@ -592,14 +592,14 @@ mod tests { .save_conversation(&ConversationMeta { local_convo_id: "local_1".into(), remote_convo_id: "remote_1".into(), - kind: ConversationKind::PrivateV1, + kind: ConversationKind::GroupV1, }) .unwrap(); storage .save_conversation(&ConversationMeta { local_convo_id: "local_2".into(), remote_convo_id: "remote_2".into(), - kind: ConversationKind::PrivateV1, + kind: ConversationKind::GroupV1, }) .unwrap(); @@ -612,7 +612,7 @@ mod tests { 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"); + assert_eq!(convos[0].kind.as_str(), "group_v1"); } #[test] diff --git a/core/storage/src/store.rs b/core/storage/src/store.rs index d53b16c..d05b05a 100644 --- a/core/storage/src/store.rs +++ b/core/storage/src/store.rs @@ -25,7 +25,6 @@ pub trait EphemeralKeyStore { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConversationKind { - PrivateV1, Unknown(String), GroupV1, } @@ -33,7 +32,6 @@ pub enum ConversationKind { impl ConversationKind { pub fn as_str(&self) -> &str { match self { - Self::PrivateV1 => "private_v1", Self::Unknown(value) => value.as_str(), Self::GroupV1 => "group_v1", } @@ -43,7 +41,6 @@ impl ConversationKind { impl From<&str> for ConversationKind { fn from(value: &str) -> Self { match value { - "private_v1" => Self::PrivateV1, "group_v1" => Self::GroupV1, other => Self::Unknown(other.to_string()), } diff --git a/crates/generic-chat/examples/message-exchange/main.rs b/crates/generic-chat/examples/message-exchange/main.rs index 025012f..1a404ed 100644 --- a/crates/generic-chat/examples/message-exchange/main.rs +++ b/crates/generic-chat/examples/message-exchange/main.rs @@ -1,33 +1,51 @@ use components::EphemeralRegistry; use logos_account::TestLogosAccount; -use logos_generic_chat::{ChatClientBuilder, Event, InProcessDelivery, MessageBus}; +use logos_generic_chat::{ChatClientBuilder, DelegateSigner, Event, InProcessDelivery, MessageBus}; use std::time::Duration; fn main() { let bus = MessageBus::default(); - let reg = EphemeralRegistry::new(); + let mut reg = EphemeralRegistry::new(); - let (mut saro, saro_events) = ChatClientBuilder::new(TestLogosAccount::new().address()) + // Mint two accounts, each with a delegate signer, and publish their device + // bundles so a peer can resolve an account address to its device. + let saro_account = TestLogosAccount::new(); + let saro_delegate = DelegateSigner::random(); + saro_account + .add_delegate_signer(&mut reg, saro_delegate.public_key()) + .unwrap(); + + let raya_account = TestLogosAccount::new(); + let raya_delegate = DelegateSigner::random(); + raya_account + .add_delegate_signer(&mut reg, raya_delegate.public_key()) + .unwrap(); + + let (mut saro, saro_events) = ChatClientBuilder::new(saro_account.address()) + .ident(saro_delegate) .transport(InProcessDelivery::new(bus.clone())) .registration(reg.clone()) .build() .unwrap(); - let (mut raya, raya_events) = ChatClientBuilder::new(TestLogosAccount::new().address()) + let (mut raya, raya_events) = ChatClientBuilder::new(raya_account.address()) + .ident(raya_delegate) .transport(InProcessDelivery::new(bus)) .registration(reg) .build() .unwrap(); - let raya_bundle = raya.create_intro_bundle().unwrap(); - #[allow(deprecated)] - saro.create_conversation(&raya_bundle, b"hello raya") - .unwrap(); + // Saro opens a direct conversation with Raya by her account address. + let saro_convo_id = saro.create_direct_conversation(raya.addr()).unwrap(); + // Wait for Raya to process the Welcome and subscribe before Saro sends, since + // InProcessDelivery only fans out to current subscribers. let raya_convo_id = match raya_events.recv_timeout(Duration::from_secs(5)).unwrap() { Event::ConversationStarted { convo_id, .. } => convo_id, other => panic!("expected ConversationStarted, got {other:?}"), }; + + saro.send_message(&saro_convo_id, b"hello raya").unwrap(); if let Event::MessageReceived { content, .. } = raya_events.recv_timeout(Duration::from_secs(5)).unwrap() { @@ -38,7 +56,6 @@ fn main() { } raya.send_message(&raya_convo_id, b"hi saro").unwrap(); - if let Event::MessageReceived { content, .. } = saro_events.recv_timeout(Duration::from_secs(5)).unwrap() { diff --git a/crates/generic-chat/src/client.rs b/crates/generic-chat/src/client.rs index 213fff5..21fdeac 100644 --- a/crates/generic-chat/src/client.rs +++ b/crates/generic-chat/src/client.rs @@ -7,7 +7,7 @@ use crossbeam_channel::{Receiver, Sender, select}; use crypto::Ed25519VerifyingKey; use libchat::{ ConversationId, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, IdentIdRef, - InboxOutcome, Introduction, PayloadOutcome, RegistrationService, + InboxOutcome, PayloadOutcome, RegistrationService, }; use logos_account::{AccountDirectory, resolve_device_ids}; use parking_lot::Mutex; @@ -150,11 +150,6 @@ where self.core.lock().installation_name().to_string() } - /// Produce a serialised introduction bundle for sharing out-of-band. - pub fn create_intro_bundle(&mut self) -> Result, ClientError> { - self.core.lock().create_intro_bundle().map_err(Into::into) - } - // Creates a conversation between two Accounts. pub fn create_direct_conversation( &mut self, @@ -218,22 +213,6 @@ where Ok(dedup_members(members)) } - /// Parse intro bundle bytes and initiate a private conversation. Outbound - /// envelopes are published by the core. Returns this side's conversation ID. - /// - /// This function will be deprecated in the future. Use `create_direct_conversation` - pub fn create_conversation( - &mut self, - intro_bundle: &[u8], - initial_content: &[u8], - ) -> Result { - let intro = Introduction::try_from(intro_bundle)?; - self.core - .lock() - .create_private_convo_v1(&intro, initial_content) - .map_err(Into::into) - } - /// List all conversation IDs known to this client. pub fn list_conversations(&self) -> Result, ClientError> { self.core.lock().list_conversations().map_err(Into::into) @@ -707,8 +686,7 @@ mod sender_check_tests { ); } - /// No credential at all (e.g. the PrivateV1 placeholder) leaves no sender to - /// attribute, so the message is dropped. + /// An empty credential leaves no sender to attribute, so the message is dropped. #[test] fn empty_credential_is_dropped() { let dir = FakeDir::default(); diff --git a/docs/adr/0001-client-event-system.md b/docs/adr/0001-client-event-system.md index 35add0c..d17b344 100644 --- a/docs/adr/0001-client-event-system.md +++ b/docs/adr/0001-client-event-system.md @@ -45,7 +45,7 @@ Crates: **app** — `bin/chat-cli`, future `logos-chat-module`; **client** — ` 3. **Two enums, mapping at the client boundary.** `PayloadOutcome` is the dispatcher-level sum of observations from one payload; `Event` is a discrete app-facing notification. The two enums are allowed to diverge: a protocol-internal observation the app does not need lives only on a core outcome type; a client-only event like `DeliveryFailed { Timeout }` lives only on `Event`. Translation is an explicit per-variant `match` inside the client — not a blanket `From` impl — to preserve that divergence as both sides grow. -4. **`ConversationClass` is a core boundary type, not a client-only one.** The protocol-versioned `ConversationKind` (`PrivateV1`, `GroupV1`, …) is a storage concern; clients only need the coarse class (`Private`, `Group`). The kind→class mapping happens in core where `NewConversation` is constructed, so adding a new `ConversationKind` is a one-line change in core's mapping site rather than a ripple into every client. The client re-exports `ConversationClass` for consumers, but the canonical definition lives in core alongside the outcome types. +4. **`ConversationClass` is a core boundary type, not a client-only one.** The protocol-versioned `ConversationKind` (`GroupV1`, …) is a storage concern; clients only need the coarse class (`Private`, `Group`). The kind→class mapping happens in core where `NewConversation` is constructed, so adding a new `ConversationKind` is a one-line change in core's mapping site rather than a ripple into every client. The client re-exports `ConversationClass` for consumers, but the canonical definition lives in core alongside the outcome types. ## Events vs errors