chore: remove the unused PrivateV1 conversation stack (#176)

PrivateV1 was the legacy prekey-handshake 1:1 protocol, superseded by
DirectV1 over InboxV2. Remove it and everything that existed only to
serve it.

- Delete PrivateV1Convo and its v1 inbox bootstrap: the Inbox,
  Introduction, and InboxHandshake types, plus the support code only they
  used (the crate::crypto module, EncryptionError, timestamp_millis, the
  PrivateV1Domain HKDF domain, and the ServiceContext test doubles).
- Drop the public entry points that exposed it: Core::create_intro_bundle,
  create_private_convo_v1, dispatch_to_inbox, and the matching
  ChatClient::create_intro_bundle / create_conversation.
- Remove ConversationKind::PrivateV1 and its "private_v1" mapping, the
  from_kind arm, and the orphaned inbox-v1 proto re-exports.
- Migrate the chat-cli /intro and /connect commands and the
  message-exchange example to create_direct_conversation.
- Delete the PrivateV1 integration test; move the sqlite conversation
  roundtrip test to GroupV1.

InboxOutcome, PayloadOutcome::Inbox, and ConversationClass::Private stay:
InboxV2 (DirectV1 and GroupV1 invites) still produces them.

Follow-ups, out of scope here:
- logos-chat-module still calls create_intro_bundle / create_conversation
  and needs a companion change.
- remote_convo_id and EphemeralKeyStore are now vestigial (only PrivateV1
  used them); dropping them is a storage-schema change for a later PR.
This commit is contained in:
osmaczko 2026-07-16 10:40:23 +02:00 committed by GitHub
parent b0de532199
commit 37ae88bfa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 51 additions and 1504 deletions

View File

@ -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 <bundle> - Connect using a bundle");
self.add_system_message("/intro - Show your address");
self.add_system_message("/connect <address> - Connect using an address");
self.add_system_message("/nickname <name> - Name the active chat");
self.add_system_message("/chats - List all chats");
self.add_system_message("/switch <name|id> - 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 <bundle>".to_string()));
return Ok(Some("Usage: /connect <address>".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();

View File

@ -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;

View File

@ -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::<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(),
encoded_credential: vec![],
}
}
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 Identified for PrivateV1Convo {
fn id(&self) -> ConversationIdRef<'_> {
&self.local_convo_id
}
}
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,
members_changed: false,
})
}
fn wakeup(&mut self, _: &mut ServiceContext<S>) -> Result<ConvoOutcome, ChatError> {
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
);
}
}

View File

@ -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<S: ExternalServices> {
services: ServiceContext<S>,
inbox: Inbox,
pq_inbox: InboxV2,
// Cache of loaded conversations
cached_convos: HashMap<String, ConvoTypeOwned<S>>,
@ -125,7 +122,6 @@ where
wakeup_service: WS,
store: CS,
) -> Result<Self, ChatError> {
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<S> {
self.create_direct_convo_v1(members)
}
pub fn create_private_convo_v1(
&mut self,
remote_bundle: &Introduction,
content: &[u8],
) -> Result<ConversationId, ChatError> {
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<S> {
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<S> {
}
}
// 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<InboxOutcome, ChatError> {
// 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<PayloadOutcome, ChatError> {
if let Some((convo, class)) = self.pq_inbox.handle_frame(&mut self.services, payload)? {
@ -479,11 +439,6 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
fn load_convo(&mut self, convo_id: &str) -> Result<Box<dyn Convo<S>>, 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<S> {
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<S> {
GroupV1Convo::load(&mut self.services, convo_id.to_string(), group_id)
}
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, 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,

View File

@ -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())
}
}

View File

@ -55,9 +55,3 @@ impl ChatError {
Self::Generic(e.to_string())
}
}
#[derive(Error, Debug)]
pub enum EncryptionError {
#[error("decryption: {0}")]
Decryption(String),
}

View File

@ -1,6 +0,0 @@
mod handler;
mod handshake;
mod introduction;
pub use handler::{Inbox, PRIVATE_V1_INBOX_ADDRESS};
pub use introduction::Introduction;

View File

@ -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<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
) -> Result<Introduction, ChatError> {
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<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
remote_bundle: &Introduction,
initial_message: &[u8],
) -> Result<(PrivateV1Convo, Vec<AddressedEncryptedPayload>), 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<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
enc_payload: EncryptedPayload,
public_key_hex: &str,
) -> Result<InboxOutcome, ChatError> {
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<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(),
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<proto::InboxHandshakeV1, ChatError> {
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<proto::InboxV1Frame, ChatError> {
// 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<String>) -> 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"
);
}
}

View File

@ -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<U32>;
pub struct InboxDomain;
impl DomainSeparator for InboxDomain {
const BYTES: &'static [u8] = b"logos_chat_inbox";
}
type InboxKeyExchange = X3Handshake<InboxDomain>;
pub struct InboxHandshake {}
impl InboxHandshake {
/// Performs
pub fn perform_as_initiator<R: RngCore + CryptoRng>(
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);
}
}

View File

@ -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<u8> {
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<R: RngCore + CryptoRng>(
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<R: RngCore + CryptoRng>(
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<Introduction> for Vec<u8> {
fn from(intro: Introduction) -> Vec<u8> {
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<Self, Self::Error> {
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<u8> = 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<u8> = 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());
}
}

View File

@ -161,7 +161,7 @@ impl InboxV2 {
convo: &GroupV1Convo,
cx: &mut ServiceContext<S>,
) -> 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<Convo> for ConversationMeta
let meta = ConversationMeta {
local_convo_id: convo.id().to_string(),

View File

@ -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

View File

@ -78,7 +78,6 @@ impl ConversationClass {
/// `Unknown(_)` yields `None`.
pub fn from_kind(kind: &ConversationKind) -> Option<Self> {
match kind {
ConversationKind::PrivateV1 => Some(Self::Private),
ConversationKind::GroupV1 => Some(Self::Group),
ConversationKind::Unknown(_) => None,
}

View File

@ -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;

View File

@ -52,74 +52,3 @@ pub(crate) struct ServiceContext<S: ExternalServices> {
/// 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<u8>,
) -> Result<(), Self::Error> {
Ok(())
}
fn retrieve(&self, _device_id: &str) -> Result<Option<Vec<u8>>, 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<IP: IdentityProvider, CS: ChatStore>
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<Self, ChatError> {
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(),
})
}
}
}

View File

@ -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 {

View File

@ -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.

View File

@ -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<PayloadOutcome> {
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:?}"),
}
}

View File

@ -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]

View File

@ -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()),
}

View File

@ -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()
{

View File

@ -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<Vec<u8>, 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<ConversationId, ClientError> {
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<Vec<ConversationId>, 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();

View File

@ -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