mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-03 13:43:18 +00:00
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:
parent
b0de532199
commit
37ae88bfa7
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -55,9 +55,3 @@ impl ChatError {
|
||||
Self::Generic(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum EncryptionError {
|
||||
#[error("decryption: {0}")]
|
||||
Decryption(String),
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
mod handler;
|
||||
mod handshake;
|
||||
mod introduction;
|
||||
|
||||
pub use handler::{Inbox, PRIVATE_V1_INBOX_ADDRESS};
|
||||
pub use introduction::Introduction;
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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()),
|
||||
}
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user