diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 37fbe71..7f2d5e4 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -153,6 +153,14 @@ impl<'a, S: ExternalServices + 'static> Core { &self.services.store } + /// The account → device directory (our account store). Used to verify that a + /// received message's claimed account actually endorses the sending device + /// before the message is surfaced. Exposed as `RegistrationService`, whose + /// `AccountDirectory` supertrait provides `fetch`. + pub fn account_directory(&self) -> &S::RS { + &self.services.registry + } + pub fn identity(&self) -> &Identity { &self.services.identity } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index e9b56a2..c6dbcf6 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -3,9 +3,11 @@ use std::thread::{self, JoinHandle}; use components::{EphemeralRegistry, ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; +use crypto::Ed25519VerifyingKey; use libchat::{ - ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, - IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService, StorageConfig, + AccountDirectory, ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, + IdentId, IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService, + StorageConfig, }; use parking_lot::Mutex; @@ -268,7 +270,7 @@ fn worker_loop( let events = { let mut core = core.lock(); match core.handle_payload(&bytes) { - Ok(outcome) => events_from_inbound(outcome), + Ok(outcome) => events_from_inbound(outcome, core.account_directory()), Err(e) => { tracing::warn!("inbound handle_payload failed: {e:?}"); vec![Event::InboundError { @@ -300,38 +302,78 @@ fn worker_loop( /// observation. For an `Inbox` outcome, [`Event::ConversationStarted`] /// precedes the message event. The convo id is wrapped into `Arc` once /// per outcome and shared across the events it produces. -fn events_from_inbound(result: PayloadOutcome) -> Vec { +fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec { match result { PayloadOutcome::Empty => Vec::new(), - PayloadOutcome::Convo(co) => convo_events(co), - PayloadOutcome::Inbox(io) => inbox_events(io), + PayloadOutcome::Convo(co) => convo_events(co, directory), + PayloadOutcome::Inbox(io) => inbox_events(io, directory), } } -fn decode_credential(encoded: Vec) { - if let Ok(data) = hex::decode(encoded) - && let Ok(cred) = DelegateCredential::try_from(data) - { - tracing::debug!(?cred, "decoded sender credential"); - // TODO: Integration Point +/// Interpret a hex account address as an Ed25519 account verifying key. +fn account_key_from_hex(addr: &str) -> Option { + let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?; + Ed25519VerifyingKey::from_bytes(&bytes).ok() +} + +/// Whether to surface a received message, given its sender credential checked +/// against the account → device directory (our account store). +/// +/// The credential binds a delegate device key to an optional account address. +/// When it claims an account, that account's published device set must include +/// this device — otherwise the account→device mapping is wrong or unconfirmable +/// and the message is dropped (`false`). A credential that claims no account (or +/// no credential at all) asserts no mapping, so it is delivered (`true`). +fn should_deliver(directory: &impl AccountDirectory, encoded: &[u8]) -> bool { + // No credential (e.g. the PrivateV1 placeholder) asserts no account mapping. + if encoded.is_empty() { + return true; + } + let Ok(data) = hex::decode(encoded) else { + tracing::warn!("sender credential is not valid hex; dropping message"); + return false; + }; + let cred = match DelegateCredential::try_from(data) { + Ok(cred) => cred, + Err(_) => { + tracing::warn!("malformed sender credential; dropping message"); + return false; + } + }; + let device = hex::encode(cred.delegate_id().as_ref()); + // An unassociated delegate asserts no account → device mapping. + let Some(account_addr) = cred.account_addr() else { + return true; + }; + let Some(account_key) = account_key_from_hex(account_addr) else { + tracing::warn!( + account_addr, + "sender account address is not a verifying key; dropping message" + ); + return false; + }; + match directory.fetch(&account_key) { + Ok(Some(set)) if set.devices.iter().any(|d| d == &device) => true, + _ => { + tracing::warn!(account_addr, %device, "account → device mapping is wrong or unconfirmable; dropping message"); + false + } } } -fn convo_events(outcome: ConvoOutcome) -> Vec { +fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec { let ConvoOutcome { convo_id, content } = outcome; content - .map(|c| { - decode_credential(c.encoded_credential); - Event::MessageReceived { - convo_id: Arc::from(convo_id), - content: c.bytes, - } + .filter(|c| should_deliver(directory, &c.encoded_credential)) + .map(|c| Event::MessageReceived { + convo_id: Arc::from(convo_id), + content: c.bytes, }) .into_iter() .collect() } -fn inbox_events(outcome: InboxOutcome) -> Vec { +fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec { let InboxOutcome { new_conversation, initial, @@ -342,8 +384,9 @@ fn inbox_events(outcome: InboxOutcome) -> Vec { convo_id: Arc::clone(&id), class: new_conversation.class, }); - if let Some(c) = initial.and_then(|co| co.content) { - decode_credential(c.encoded_credential); + if let Some(c) = initial.and_then(|co| co.content) + && should_deliver(directory, &c.encoded_credential) + { events.push(Event::MessageReceived { convo_id: Arc::clone(&id), content: c.bytes, @@ -351,3 +394,150 @@ fn inbox_events(outcome: InboxOutcome) -> Vec { } events } + +#[cfg(test)] +mod sender_check_tests { + use std::collections::HashMap; + + use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; + use libchat::{DeviceSet, SignedDeviceBundle}; + + use super::should_deliver; + use crate::delegate::DelegateCredential; + + /// In-test account → device directory. Holds device id sets keyed by the hex + /// account key, and can be made to fail to simulate a directory outage. + #[derive(Debug, Default)] + struct FakeDir { + bundles: HashMap>, + fail: bool, + } + + impl FakeDir { + /// Publish `devices` (verifying keys) as `account`'s device set. + fn with_devices(account: &Ed25519VerifyingKey, devices: &[&Ed25519VerifyingKey]) -> Self { + let mut bundles = HashMap::new(); + bundles.insert( + hex::encode(account.as_ref()), + devices.iter().map(|d| hex::encode(d.as_ref())).collect(), + ); + Self { + bundles, + fail: false, + } + } + } + + impl libchat::AccountDirectory for FakeDir { + type Error = &'static str; + + fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> { + Ok(()) + } + + fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error> { + if self.fail { + return Err("directory unavailable"); + } + Ok(self + .bundles + .get(&hex::encode(account.as_ref())) + .map(|devices| DeviceSet { + lamport: 1, + devices: devices.clone(), + })) + } + } + + fn key() -> Ed25519VerifyingKey { + Ed25519SigningKey::generate().verifying_key() + } + + /// Encode a credential exactly as it travels on the wire: the hex of the + /// serialized TLV, matching the MLS leaf credential's content bytes. + fn encoded(cred: DelegateCredential) -> Vec { + hex::encode(cred.serialize()).into_bytes() + } + + /// The account published a device set that includes the sending device — the + /// claim checks out, so the message is delivered. + #[test] + fn verified_sender_is_delivered() { + let account = key(); + let device = key(); + let dir = FakeDir::with_devices(&account, &[&device]); + let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + assert!(should_deliver(&dir, &encoded(cred))); + } + + /// The account published a device set that does NOT include the sending + /// device — a spoofed account claim, so the message is dropped. + #[test] + fn contradicted_claim_is_dropped() { + let account = key(); + let endorsed = key(); + let spoofer = key(); + let dir = FakeDir::with_devices(&account, &[&endorsed]); + let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref())); + assert!(!should_deliver(&dir, &encoded(cred))); + } + + /// A delegate that claims no account makes no mapping to contradict. + #[test] + fn unassociated_sender_is_delivered() { + let dir = FakeDir::default(); + let cred = DelegateCredential::unassociated(&key()); + assert!(should_deliver(&dir, &encoded(cred))); + } + + /// The claimed account has never published a device set — the mapping is + /// missing, so the message is dropped. + #[test] + fn unpublished_account_is_dropped() { + let account = key(); + let device = key(); + let dir = FakeDir::default(); // nothing published + let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + assert!(!should_deliver(&dir, &encoded(cred))); + } + + /// A directory outage leaves the mapping unconfirmed, so the message is + /// dropped rather than delivered on an unverified claim. + #[test] + fn directory_error_is_dropped() { + let account = key(); + let device = key(); + let dir = FakeDir { + fail: true, + ..Default::default() + }; + let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + assert!(!should_deliver(&dir, &encoded(cred))); + } + + /// No credential at all (e.g. the PrivateV1 placeholder) asserts no account + /// mapping and is delivered. + #[test] + fn empty_credential_is_delivered() { + let dir = FakeDir::default(); + assert!(should_deliver(&dir, b"")); + } + + /// Bytes that aren't a well-formed credential leave the sender's mapping + /// undeterminable, so the message is dropped. + #[test] + fn malformed_credential_is_dropped() { + let dir = FakeDir::default(); + assert!(!should_deliver(&dir, b"not hex")); + assert!(!should_deliver(&dir, hex::encode([0u8; 4]).as_bytes())); + } + + /// An account address that isn't a verifying key can't be looked up, so the + /// claim is unconfirmable and the message is dropped. + #[test] + fn non_key_account_address_is_dropped() { + let dir = FakeDir::default(); + let cred = DelegateCredential::associated(&key(), "user@example.com"); + assert!(!should_deliver(&dir, &encoded(cred))); + } +} diff --git a/crates/client/src/delegate.rs b/crates/client/src/delegate.rs index 4bde122..c2cc712 100644 --- a/crates/client/src/delegate.rs +++ b/crates/client/src/delegate.rs @@ -89,6 +89,17 @@ impl DelegateCredential { } } + /// The delegate (device / LocalIdentity) verifying key this credential names. + pub fn delegate_id(&self) -> &Ed25519VerifyingKey { + &self.delegate_id + } + + /// The account this delegate claims to act for, if it is associated. The + /// claim is unverified — confirm it against the account directory. + pub fn account_addr(&self) -> Option<&str> { + self.account_addr.as_deref() + } + pub fn serialize(self) -> Vec { let mut data = Vec::new(); data.extend_from_slice(&[0x23, 0x23]); diff --git a/crates/client/tests/saro_and_raya.rs b/crates/client/tests/saro_and_raya.rs index 9b80652..0b80a00 100644 --- a/crates/client/tests/saro_and_raya.rs +++ b/crates/client/tests/saro_and_raya.rs @@ -2,13 +2,31 @@ use std::time::Duration; use components::EphemeralRegistry; use crossbeam_channel::{Receiver, Sender}; -use libchat::IdentityProvider; +use crypto::Ed25519VerifyingKey; +use libchat::{AccountDirectory, IdentityProvider, SignedDeviceBundle, encode_bundle_payload}; use logos_account::TestLogosAccount; use logos_chat::{ AddressedEnvelope, ChatClient, DelegateSigner, DeliveryService, Event, InProcessDelivery, MessageBus, StorageConfig, Transport, }; +/// 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, + account: &TestLogosAccount, + device: &Ed25519VerifyingKey, +) { + let payload = encode_bundle_payload(0, std::slice::from_ref(device)); + let signature = account.sign(&payload); + let bundle = SignedDeviceBundle { + account_pub: account.public_key().clone(), + payload, + signature, + }; + reg.publish(&bundle).unwrap(); +} + /// Block until the next event arrives and matches; panic on timeout/mismatch. fn expect_event(events: &Receiver, label: &str, mut f: F) -> T where @@ -26,18 +44,20 @@ fn direct_v1_integration() { let saro_delivery = InProcessDelivery::new(bus.clone()); let raya_delivery = InProcessDelivery::new(bus); - let reg_service = EphemeralRegistry::new(); + let mut reg_service = EphemeralRegistry::new(); - // Create Accounts, Deletage and Associate the two. + // Create accounts and delegates, associate each delegate with its account + // address, and publish a device bundle so the receiver can verify the + // account → device mapping carried in the sender's credential. let saro_account = TestLogosAccount::new("Saro"); let mut saro_delegate = DelegateSigner::random(); - // TODO: Submit Delegate to Account for auth. - saro_delegate.associate(saro_account.id().to_string()); + saro_delegate.associate(hex::encode(saro_account.public_key().as_ref())); + publish_device_bundle(&mut reg_service, &saro_account, saro_delegate.public_key()); let raya_account = TestLogosAccount::new("Raya"); let mut raya_delegate = DelegateSigner::random(); - // TODO: Submit Delegate to Account for auth. - raya_delegate.associate(raya_account.id().to_string()); + raya_delegate.associate(hex::encode(raya_account.public_key().as_ref())); + publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key()); let raya_delegate_id = raya_delegate.id().clone(); let (mut saro, _saro_events) =