diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 7f2d5e4..8cc72ff 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -112,7 +112,11 @@ where store: CS, ) -> Result { let inbox = Inbox::new(&identity); - let ident_id = ident.id().clone(); + // InboxV2 (1:1) routes on routing_id, not the credential id: an + // account-associated delegate receives under its account address, which + // is what a peer resolves and addresses the Welcome to. The MLS identity + // below still wraps the raw `ident`, so the credential is unchanged. + let ident_id = ident.routing_id(); let mls_identity = MlsIdentityProvider::new(ident); let mls_provider = MlsEphemeralPqProvider::new().map_err(ChatError::generic)?; let causal = CausalHistoryStore::new(); diff --git a/core/shared-traits/src/lib.rs b/core/shared-traits/src/lib.rs index 06316cb..8e7f910 100644 --- a/core/shared-traits/src/lib.rs +++ b/core/shared-traits/src/lib.rs @@ -36,4 +36,11 @@ pub trait IdentityProvider { fn display_name(&self) -> String; fn sign(&self, payload: &[u8]) -> Ed25519Signature; fn public_key(&self) -> &Ed25519VerifyingKey; + + /// Identifier this identity receives 1:1 (InboxV2) messages under; defaults + /// to [`id`](Self::id). An account-associated delegate overrides it so + /// routing keys on the account address, not the credential. + fn routing_id(&self) -> IdentId { + self.id().clone() + } } diff --git a/crates/client/src/delegate.rs b/crates/client/src/delegate.rs index c2cc712..8d0c535 100644 --- a/crates/client/src/delegate.rs +++ b/crates/client/src/delegate.rs @@ -13,7 +13,6 @@ pub struct DelegateSigner { signing_key: Ed25519SigningKey, verifying_key: Ed25519VerifyingKey, identifier: IdentId, - account_addr: Option, } impl DelegateSigner { @@ -21,12 +20,11 @@ impl DelegateSigner { pub fn random() -> Self { let signing_key = Ed25519SigningKey::generate(); let verifying_key = signing_key.verifying_key(); - let identifier = DelegateCredential::unassociated(&verifying_key).into(); + let identifier: IdentId = DelegateCredential::unassociated(&verifying_key).into(); Self { signing_key, verifying_key, identifier, - account_addr: None, } } @@ -34,11 +32,12 @@ impl DelegateSigner { pub fn associate(&mut self, account_addr: AccountAddr) { self.identifier = DelegateCredential::associated(&self.verifying_key, account_addr.as_str()).into(); - self.account_addr = Some(account_addr); } - pub fn account_addr(&self) -> Option<&str> { - self.account_addr.as_deref() + pub fn account_addr(&self) -> Option { + DelegateCredential::try_from(self.identifier.clone()) + .ok() + .and_then(|c| c.account_addr().map(String::from)) } } @@ -47,6 +46,12 @@ impl IdentityProvider for DelegateSigner { &self.identifier } + fn routing_id(&self) -> IdentId { + self.account_addr() + .map(IdentId::new) + .unwrap_or_else(|| self.identifier.clone()) + } + fn display_name(&self) -> String { trunc(self.identifier.as_str()) } @@ -298,4 +303,24 @@ mod tests { Err(ClientError::BadlyFormedCredential) )); } + + #[test] + fn unassociated_routing_id_equals_id() { + let signer = DelegateSigner::random(); + assert!(signer.account_addr().is_none()); + let id = signer.id().clone(); + assert_eq!(signer.routing_id(), id); + } + + #[test] + fn associated_routing_id_derives_account_addr() { + let addr = "alice@libchat.example"; + let mut signer = DelegateSigner::random(); + signer.associate(addr.to_string()); + assert_eq!(signer.account_addr().as_deref(), Some(addr)); + assert_eq!(signer.routing_id(), IdentId::new(addr)); + // id() stays the full credential, distinct from the routing address. + let id = signer.id().clone(); + assert_ne!(signer.routing_id(), id); + } } diff --git a/crates/client/tests/saro_and_raya.rs b/crates/client/tests/saro_and_raya.rs index 091083c..0224d35 100644 --- a/crates/client/tests/saro_and_raya.rs +++ b/crates/client/tests/saro_and_raya.rs @@ -1,9 +1,14 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::Duration; use components::EphemeralRegistry; use crossbeam_channel::{Receiver, Sender}; use crypto::Ed25519VerifyingKey; -use libchat::{AccountDirectory, IdentityProvider, SignedDeviceBundle, encode_bundle_payload}; +use libchat::{ + AccountDirectory, DeviceSet, IdentityProvider, RegistrationService, SignedDeviceBundle, + encode_bundle_payload, verify_bundle, +}; use logos_account::TestLogosAccount; use logos_chat::{ AddressedEnvelope, ChatClient, ChatClientBuilder, DelegateSigner, DeliveryService, Event, @@ -13,7 +18,7 @@ use logos_chat::{ /// Publish a signed device bundle endorsing `device` as a device of `account`, /// so a receiver can verify the sender's account → device mapping. fn publish_device_bundle( - reg: &mut EphemeralRegistry, + reg: &mut impl AccountDirectory, account: &TestLogosAccount, device: &Ed25519VerifyingKey, ) { @@ -146,6 +151,122 @@ fn direct_v1_standalone_integration() { }); } +/// Test registry that keys keypackages by `hex(public_key())`, exactly as the +/// deployed `HttpRegistry` does (the server keys by the device verifying key for +/// proof-of-possession). [`EphemeralRegistry`] instead keys by `id()`, which +/// collapses the account/credential split and so cannot exercise an associated +/// invitee: the account directory resolves a shared account address to a device +/// key, and the keypackage must be retrievable under that device key. +#[derive(Clone, Default)] +struct DeviceKeyedRegistry { + key_packages: Arc>>>, + installations: Arc>>, +} + +impl std::fmt::Debug for DeviceKeyedRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DeviceKeyedRegistry") + } +} + +impl RegistrationService for DeviceKeyedRegistry { + type Error = String; + + fn register( + &mut self, + identity: &dyn IdentityProvider, + key_bundle: Vec, + ) -> Result<(), String> { + self.key_packages + .lock() + .unwrap() + .insert(hex::encode(identity.public_key().as_ref()), key_bundle); + Ok(()) + } + + fn retrieve(&self, device_id: &str) -> Result>, String> { + Ok(self.key_packages.lock().unwrap().get(device_id).cloned()) + } +} + +impl AccountDirectory for DeviceKeyedRegistry { + type Error = String; + + fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), String> { + self.installations + .lock() + .unwrap() + .insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone()); + Ok(()) + } + + fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, String> { + let Some(bundle) = self + .installations + .lock() + .unwrap() + .get(&hex::encode(account.as_ref())) + .cloned() + else { + return Ok(None); + }; + verify_bundle(account, &bundle) + .map(Some) + .map_err(|e| e.to_string()) + } +} + +/// Regression test for the account-flow Welcome-routing fix (`routing_id`). The +/// invitee (raya) is an account-associated delegate, so the address the inviter +/// shares is raya's *account* key. Before the fix raya's InboxV2 subscribed and +/// gated on her *credential* id, so the Welcome — routed to the account address — +/// never reached her; with `routing_id` she routes on her account address, so it +/// lands and she joins. Needs a `hex(public_key())`-keyed registry because the +/// account directory resolves raya's account to her device key. +#[test] +fn direct_v1_associated_invitee_receives_welcome() { + let bus = MessageBus::default(); + let mut reg = DeviceKeyedRegistry::default(); + + let raya_account = TestLogosAccount::new("Raya"); + let raya_account_id = hex::encode(raya_account.public_key().as_ref()); + let mut raya_delegate = DelegateSigner::random(); + raya_delegate.associate(raya_account_id.clone()); + publish_device_bundle(&mut reg, &raya_account, raya_delegate.public_key()); + + let (mut saro, _saro_events) = ChatClientBuilder::new() + .transport(InProcessDelivery::new(bus.clone())) + .registration(reg.clone()) + .build() + .expect("client create"); + let (raya, raya_events) = ChatClientBuilder::new() + .ident(raya_delegate) + .transport(InProcessDelivery::new(bus.clone())) + .registration(reg.clone()) + .build() + .expect("client create"); + + // Raya is addressed by her account id (routing_id), not her credential TLV. + assert_eq!(raya.addr(), raya_account_id.as_str()); + + // Saro opens the conversation with raya's account address, shared out of band. + let convo_id = saro.create_direct_conversation(&raya_account_id).unwrap(); + + expect_event(&raya_events, "ConversationStarted", |e| match e { + Event::ConversationStarted { .. } => Ok(()), + other => Err(other), + }); + + saro.send_message(&convo_id, b"hey raya").unwrap(); + expect_event(&raya_events, "MessageReceived", |e| match e { + Event::MessageReceived { content, .. } => { + assert_eq!(content.as_slice(), b"hey raya"); + Ok(()) + } + other => Err(other), + }); +} + #[test] fn saro_raya_message_exchange() { let bus = MessageBus::default();