Merge 5d02196e6a930558a874ff7d059e6f5bef325829 into ebae3317d6dba08af924b937abe2be9b62404abc

This commit is contained in:
osmaczko 2026-06-29 20:27:22 +02:00 committed by GitHub
commit ad9796a89a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 143 additions and 4 deletions

View File

@ -112,7 +112,11 @@ where
store: CS,
) -> Result<Self, ChatError> {
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().clone();
let mls_identity = MlsIdentityProvider::new(ident);
let mls_provider = MlsEphemeralPqProvider::new().map_err(ChatError::generic)?;
let causal = CausalHistoryStore::new();

View File

@ -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) -> IdentIdRef<'_> {
self.id()
}
}

View File

@ -13,6 +13,7 @@ pub struct DelegateSigner {
signing_key: Ed25519SigningKey,
verifying_key: Ed25519VerifyingKey,
identifier: IdentId,
routing_id: IdentId,
account_addr: Option<AccountAddr>,
}
@ -21,10 +22,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,
routing_id: identifier.clone(),
identifier,
account_addr: None,
}
@ -34,6 +36,7 @@ impl DelegateSigner {
pub fn associate(&mut self, account_addr: AccountAddr) {
self.identifier =
DelegateCredential::associated(&self.verifying_key, account_addr.as_str()).into();
self.routing_id = IdentId::new(account_addr.clone());
self.account_addr = Some(account_addr);
}
@ -47,6 +50,10 @@ impl IdentityProvider for DelegateSigner {
&self.identifier
}
fn routing_id(&self) -> libchat::IdentIdRef<'_> {
&self.routing_id
}
fn display_name(&self) -> String {
trunc(self.identifier.as_str())
}

View File

@ -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<Error = String>,
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<Mutex<HashMap<String, Vec<u8>>>>,
installations: Arc<Mutex<HashMap<String, SignedDeviceBundle>>>,
}
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<u8>,
) -> 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<Option<Vec<u8>>, 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<Option<DeviceSet>, 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();