diff --git a/Cargo.lock b/Cargo.lock index 7e7467b4..129e1325 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "clap", "crossbeam-channel", "crossterm 0.29.0", + "hex", "logos-account", "logos-chat", "ratatui", diff --git a/bin/chat-cli/Cargo.toml b/bin/chat-cli/Cargo.toml index 71da08b6..e2153639 100644 --- a/bin/chat-cli/Cargo.toml +++ b/bin/chat-cli/Cargo.toml @@ -19,6 +19,7 @@ arboard = "3" base64 = "0.22" clap = { version = "4", features = ["derive"] } crossterm = "0.29" +hex = "0.4.3" ratatui = "0.29" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 30b9e24f..c689e36b 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -5,7 +5,10 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use arboard::Clipboard; use crossbeam_channel::Receiver; -use logos_chat::{AccountDirectory, ChatClient, ChatStore, Event, RegistrationService, Transport}; +use logos_chat::{ + AccountDirectory, ChatClient, ChatStore, Event, LogosAuthVerifier, RegistrationService, + Transport, +}; use serde::{Deserialize, Serialize}; use crate::utils::now; @@ -47,7 +50,7 @@ where R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { - pub client: ChatClient, + pub client: ChatClient, events: Receiver, pub state: AppState, /// Ephemeral command output — not persisted, cleared on chat switch. @@ -65,7 +68,7 @@ where S: ChatStore + Send, { pub fn new( - client: ChatClient, + client: ChatClient, events: Receiver, user_name: &str, data_dir: &Path, @@ -253,7 +256,8 @@ where Ok(Some("Help displayed".to_string())) } "/intro" => { - let address = self.client.addr().to_string(); + // The address is bytes; hex is the shareable form users paste. + let address = hex::encode(self.client.addr()); 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)) @@ -268,10 +272,13 @@ where if args.is_empty() { return Ok(Some("Usage: /connect
".to_string())); } + let Ok(address) = hex::decode(args) else { + return Ok(Some("Address must be hex".to_string())); + }; let initial = format!("Hello from {}!", self.user_name); let chat_id = self .client - .create_direct_conversation(args) + .create_direct_conversation(&address) .map_err(|e| anyhow::anyhow!("{e:?}"))?; self.client .send_message(&chat_id, initial.as_bytes()) diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index bf110a30..f3f9de15 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -9,8 +9,8 @@ use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; use crossbeam_channel::Receiver; use logos_chat::{ - AccountDirectory, ChatClient, ChatStore, Event, LogosConfig, P2pConfig, RegistrationService, - RegistryPublishMode, Transport, + AccountDirectory, ChatClient, ChatStore, Event, LogosAuthVerifier, LogosConfig, P2pConfig, + RegistrationService, RegistryPublishMode, Transport, }; use app::ChatApp; @@ -161,7 +161,7 @@ fn db_path(cli: &Cli) -> Result { } fn launch_tui( - client: ChatClient, + client: ChatClient, events: Receiver, cli: &Cli, ) -> Result<()> diff --git a/core/account/src/account.rs b/core/account/src/account.rs index 6f40d1c7..c01408e8 100644 --- a/core/account/src/account.rs +++ b/core/account/src/account.rs @@ -1,183 +1,247 @@ +//! Test-only account and account-service implementations: in-memory +//! transport, production contract. The publish gate (signature, validity, +//! strict extension) is enforced exactly as a real service would. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; -use crate::directory::{AccountDirectory, SignedDeviceBundle, encode_bundle_payload}; - -/// Failures updating an account's device bundle in the directory. -#[derive(Debug, thiserror::Error)] -pub enum AddDelegateSignerError { - #[error("directory: {0}")] - Directory(String), - #[error("directory returned a malformed device id")] - MalformedDeviceId, - #[error("directory returned a malformed device key")] - MalformedDeviceKey, +use crate::{ + AccountAddr, AccountDirectory, AccountEntry, AccountError, AccountLog, AccountRegistry, + EntryData, Lamport, SignedAccountLog, SignedDeviceBundle, encode_bundle_payload, + verify_extension, verify_log, +}; + +/// Logs "published" by accounts, keyed by address. +type SharedLogs = Arc>>; + +/// In-memory account service: the shared backend for a fleet of test +/// accounts, and the registry that answers questions about them. +#[derive(Clone, Debug, Default)] +pub struct TestAccountService { + logs: SharedLogs, } -/// A Test Focused LogosAccount. -/// The test account is not persisted. -/// This account type should not be used in a production system. -pub struct TestLogosAccount { - signing_key: Ed25519SigningKey, - verifying_key: Ed25519VerifyingKey, +impl TestAccountService { + pub fn new() -> Self { + Self::default() + } + + /// A new account publishing to this service's shared backend. + pub fn account(&self) -> TestLogosAccount { + TestLogosAccount::with_service(self.clone()) + } + + /// The publish gate a real service runs: signature under the claimed + /// address, strict extension of whatever is already stored. + fn publish(&self, addr: &AccountAddr, log: SignedAccountLog) -> Result<(), AccountError> { + verify_log(addr, &log)?; + let mut logs = self.logs.lock().expect("poisoned"); + if let Some(previous) = logs.get(addr) { + verify_extension(&previous.payload, &log.payload)?; + } + logs.insert(addr.clone(), log); + Ok(()) + } } -impl Default for TestLogosAccount { - fn default() -> Self { - Self::new() +impl AccountRegistry for TestAccountService { + type Error = AccountError; + + fn endorsed_ed25519_keys( + &self, + addr: &AccountAddr, + ) -> Result>, Self::Error> { + let logs = self.logs.lock().expect("poisoned"); + let Some(signed) = logs.get(addr) else { + return Ok(None); + }; + let log = verify_log(addr, signed)?; + log.live_entries() + .iter() + .map(|data| match data { + // A signed log endorsing a non-key is the account's error, + // not a lookup miss — surface it rather than skip it. + EntryData::Ed25519Key(bytes) => Ed25519VerifyingKey::from_bytes(bytes) + .map_err(|_| AccountError::Generic("endorsed key is invalid".into())), + }) + .collect::, _>>() + .map(Some) } } +/// A test-focused account: holds its signing key and working log, publishes +/// through a [`TestAccountService`]. Not persisted; not for production. +pub struct TestLogosAccount { + signing_key: Ed25519SigningKey, + addr: AccountAddr, + log: AccountLog, + service: TestAccountService, +} + impl TestLogosAccount { + /// An account with its own private backend. pub fn new() -> Self { + TestAccountService::new().account() + } + + pub fn with_service(service: TestAccountService) -> Self { let signing_key = Ed25519SigningKey::generate(); - let verifying_key = signing_key.verifying_key(); + let addr = AccountAddr::from(&signing_key.verifying_key()); Self { signing_key, - verifying_key, + addr, + log: AccountLog::new(vec![]).expect("empty log is valid"), + service, } } +} - /// The account verifying key; its hex is the account address peers share. - pub fn public_key(&self) -> &Ed25519VerifyingKey { - &self.verifying_key +impl Default for TestLogosAccount { + fn default() -> Self { + Self::new() } +} - /// The account address peers share: the hex of the verifying key. - pub fn address(&self) -> String { - hex::encode(self.verifying_key.as_ref()) +// Inherent for now — the write-side trait (AccountProvider) is parked in +// lib.rs; these become its impl when it lands. +impl TestLogosAccount { + pub fn address(&self) -> &AccountAddr { + &self.addr } - /// Add `signer` (the delegate signer's verifying key) to this account's directory bundle. + /// Endorse `key` on this account: append it to the log, sign the log whole, + /// and publish it. /// - /// Fetches the current (verified) device set, adds the signer if absent, - /// bumps the lamport, re-signs, and publishes. Safe to call repeatedly: - /// an unchanged set is simply re-published, which also refreshes the - /// server's retention clock. The account signs internally; its key never - /// leaves this type. - pub fn add_delegate_signer( - &self, + /// `directory` is transitional — clients still resolve an account through + /// [`AccountDirectory`] rather than reading endorsements from + /// [`AccountRegistry`], so every endorsement also mirrors the account's live + /// key set there as a signed device bundle. The parameter goes away once + /// they read the registry. + pub fn endorse_ed25519_signer( + &mut self, directory: &mut D, - signer: &Ed25519VerifyingKey, - ) -> Result<(), AddDelegateSignerError> { - // Start from the devices already registered so the account's other - // installations are preserved across the upsert. - let existing = directory - .fetch(&self.verifying_key) - .map_err(|e| AddDelegateSignerError::Directory(e.to_string()))?; - let (mut devices, next_lamport) = match existing { - Some(set) => { - let mut keys = Vec::with_capacity(set.devices.len() + 1); - for hex_id in &set.devices { - let bytes: [u8; 32] = hex::decode(hex_id) - .ok() - .and_then(|b| b.try_into().ok()) - .ok_or(AddDelegateSignerError::MalformedDeviceId)?; - let key = Ed25519VerifyingKey::from_bytes(&bytes) - .map_err(|_| AddDelegateSignerError::MalformedDeviceKey)?; - keys.push(key); - } - (keys, set.lamport + 1) - } - None => (Vec::new(), 0), - }; - - if !devices.iter().any(|d| d.as_ref() == signer.as_ref()) { - devices.push(signer.clone()); - } - - let payload = encode_bundle_payload(next_lamport, &devices); - let signature = self.signing_key.sign(&payload); + key: &Ed25519VerifyingKey, + ) -> Result<(), AccountError> { + self.append_ed25519_endorsement(key)?; + + // TODO: delete with the directory — the `directory` parameter, everything + // below, and the bundle imports; `append_ed25519_endorsement` is what's left. + let devices = self + .log + .live_entries() + .iter() + .map(|data| match data { + EntryData::Ed25519Key(bytes) => Ed25519VerifyingKey::from_bytes(bytes) + .map_err(|_| AccountError::Generic("endorsed key is invalid".into())), + }) + .collect::, _>>()?; + + // Every endorsement appends an entry, so the log's length is a version + // that only ever climbs — what the directory demands of a republish. + let payload = encode_bundle_payload(self.log.entries().len() as Lamport, &devices); let bundle = SignedDeviceBundle { - account_pub: self.verifying_key.clone(), + account_pub: self.signing_key.verifying_key(), + signature: self.signing_key.sign(&payload), payload, - signature, }; - directory .publish(&bundle) - .map_err(|e| AddDelegateSignerError::Directory(e.to_string())) + .map_err(|e| AccountError::Generic(e.to_string())) + } + + /// The endorsement itself: extend the log, sign it, publish it to the + /// account service. What [`Self::endorse_ed25519_signer`] reduces to once + /// the directory mirror is gone. + fn append_ed25519_endorsement( + &mut self, + key: &Ed25519VerifyingKey, + ) -> Result<(), AccountError> { + let device = key.as_ref().try_into().expect("ed25519 keys are 32 bytes"); + let mut entries = self.log.entries().to_vec(); + entries.push(AccountEntry::Add(EntryData::Ed25519Key(device))); + + // Sign-side validation gate: never sign a log that does not replay. + let log = AccountLog::new(entries)?; + let payload = log.encode(); + let signed = SignedAccountLog { + signature: self.signing_key.sign(payload.as_bytes()), + payload, + }; + self.service.publish(&self.addr, signed)?; + self.log = log; + Ok(()) } } #[cfg(test)] mod tests { - use crate::directory::{DeviceSet, verify_bundle}; - use super::*; - /// Minimal in-test directory: stores the latest bundle, verifies on fetch. - #[derive(Debug, Default)] - struct FakeDir(Option); - - impl AccountDirectory for FakeDir { - type Error = crate::directory::BundleError; - fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error> { - self.0 = Some(bundle.clone()); - Ok(()) - } - fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error> { - self.0 - .as_ref() - .map(|b| verify_bundle(account, b)) - .transpose() - } + fn device() -> Ed25519VerifyingKey { + Ed25519SigningKey::generate().verifying_key() } - fn device_set(dir: &FakeDir, account: &TestLogosAccount) -> (u64, Vec) { - let set = dir - .fetch(account.public_key()) - .unwrap() - .expect("bundle published"); - (set.lamport, set.devices) - } - - /// First publish for an account starts at lamport 0 with the one device. + /// endorse → the shared service resolves the key for that account. #[test] - fn first_add_delegate_signer_lists_the_signer() { - let mut dir = FakeDir::default(); - let account = TestLogosAccount::new(); - let device = Ed25519SigningKey::generate().verifying_key(); + fn endorsed_signer_is_resolvable() { + let srv = TestAccountService::new(); + let mut account = srv.account(); + let dev = device(); - account.add_delegate_signer(&mut dir, &device).unwrap(); + account.append_ed25519_endorsement(&dev).unwrap(); - let (lamport, devices) = device_set(&dir, &account); - assert_eq!(lamport, 0); - assert_eq!(devices, vec![hex::encode(device.as_ref())]); + assert!(srv.is_ed25519_endorsed(&dev, account.address()).unwrap()); + assert!( + !srv.is_ed25519_endorsed(&device(), account.address()) + .unwrap() + ); } - /// A second device is merged into the existing set with a bumped lamport, - /// preserving the first device. + /// An account that never published is unknown, not empty. #[test] - fn add_delegate_signer_merges_and_bumps_lamport() { - let mut dir = FakeDir::default(); - let account = TestLogosAccount::new(); - let first = Ed25519SigningKey::generate().verifying_key(); - let second = Ed25519SigningKey::generate().verifying_key(); - - account.add_delegate_signer(&mut dir, &first).unwrap(); - account.add_delegate_signer(&mut dir, &second).unwrap(); - - let (lamport, devices) = device_set(&dir, &account); - assert_eq!(lamport, 1); - assert_eq!( - devices, - vec![hex::encode(first.as_ref()), hex::encode(second.as_ref())] + fn unpublished_account_is_unknown() { + let srv = TestAccountService::new(); + let account = srv.account(); + assert!( + srv.endorsed_ed25519_keys(account.address()) + .unwrap() + .is_none() ); } - /// Re-adding an already-listed device keeps the set and still bumps the - /// lamport (a refresh, not a duplicate). + /// Each endorsement extends the log; the registry sees the full key set. #[test] - fn re_adding_a_device_is_idempotent_on_the_set() { - let mut dir = FakeDir::default(); - let account = TestLogosAccount::new(); - let device = Ed25519SigningKey::generate().verifying_key(); + fn endorsements_accumulate() { + let srv = TestAccountService::new(); + let mut account = srv.account(); + let (a, b) = (device(), device()); - account.add_delegate_signer(&mut dir, &device).unwrap(); - account.add_delegate_signer(&mut dir, &device).unwrap(); + account.append_ed25519_endorsement(&a).unwrap(); + account.append_ed25519_endorsement(&b).unwrap(); - let (lamport, devices) = device_set(&dir, &account); - assert_eq!(lamport, 1); - assert_eq!(devices, vec![hex::encode(device.as_ref())]); + let keys = srv + .endorsed_ed25519_keys(account.address()) + .unwrap() + .unwrap(); + assert_eq!(keys, vec![a, b]); + } + + /// The publish gate refuses a log signed by anyone but the account. + #[test] + fn publish_rejects_wrong_signer() { + let srv = TestAccountService::new(); + let account = srv.account(); + let imposter = Ed25519SigningKey::generate(); + + let payload = AccountLog::new(vec![]).unwrap().encode(); + let forged = SignedAccountLog { + signature: imposter.sign(payload.as_bytes()), + payload, + }; + assert!(srv.publish(account.address(), forged).is_err()); } } diff --git a/core/account/src/account_log.rs b/core/account/src/account_log.rs new file mode 100644 index 00000000..99a6d58d --- /dev/null +++ b/core/account/src/account_log.rs @@ -0,0 +1,176 @@ +//! Signed account operation log: the append-only record of the keys and data +//! an account has endorsed. +//! +//! ```text +//! SignedAccountLog payload + account signature over its exact bytes +//! └── EncodedAccountLog the log as canonical bytes (wire form — see codec) +//! └── AccountLog the log as validated entries (working form) +//! └── AccountEntry Add(EntryData) | Remove { index } +//! └── EntryData Ed25519Key +//! ``` +//! +//! Invariants: +//! - Append-only: a newer log strictly extends the older one +//! ([`verify_extension`](crate::verify_extension)). There is no version +//! counter — a longer log is a newer log. A log that is longer but does not +//! extend the old one has rewritten history: either the signer is showing +//! different histories to different readers, or the account key is +//! compromised. +//! - A `Remove` tombstones a strictly earlier, still-live `Add`; anything else +//! rejects the whole log — fail closed ([`AccountLog::new`]). +//! +//! Replaying ([`AccountLog::live_entries`]) yields the account's current state. + +use crypto::Ed25519Signature; + +use crate::error::AccountLogError; + +/// An [`AccountLog`] in its canonical byte encoding, plus the account's +/// signature over exactly those bytes. +/// +/// The account key is not carried: the account address *is* the verifying key, +/// supplied by the caller on verify. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedAccountLog { + pub payload: EncodedAccountLog, + pub signature: Ed25519Signature, +} + +/// An [`AccountLog`] as canonical bytes — exactly what is signed and +/// transmitted. Holding one proves the bytes decode to a valid log: construct +/// via [`AccountLog::encode`] or [`parse`](Self::parse). Byte layout: see +/// [`AccountLog::encode`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncodedAccountLog(pub(crate) Vec); + +/// The log as a validated entry list. Construction checks every `Remove`, so +/// a held log always replays ([`live_entries`](Self::live_entries) cannot +/// fail). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountLog { + entries: Vec, +} + +/// One operation in the log. An entry's index is its position — derived, not +/// stored, so an entry cannot lie about where it sits. One enum rather than +/// separate op/data fields: illegal combinations are unrepresentable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountEntry { + /// Endorse new data under this account. + Add(EntryData), + /// Tombstone the `Add` at position `index`. Must point at a strictly + /// earlier, still-live `Add`; anything else rejects the whole log — + /// fail closed, so verifiers can never skip their way to different sets. + Remove { index: u32 }, +} + +/// Data an account can endorse. Will grow beyond keys; non_exhaustive so new +/// kinds are not a breaking change. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum EntryData { + /// A device (LocalIdentity) verifying key. + Ed25519Key([u8; 32]), +} + +impl AccountLog { + /// Validate `entries` as a log (see [`AccountEntry::Remove`]). + /// [`EncodedAccountLog::parse`] applies the same gate to received bytes, + /// so a broken log — un-rewritable once published — never gets published. + pub fn new(entries: Vec) -> Result { + removed_flags(&entries)?; + Ok(Self { entries }) + } + + pub fn entries(&self) -> &[AccountEntry] { + &self.entries + } + + /// Replay the log into its live entry set — the account's current state, + /// in add order. + pub fn live_entries(&self) -> Vec { + let removed = removed_flags(&self.entries).expect("validated at construction"); + self.entries + .iter() + .enumerate() + .filter_map(|(i, entry)| match entry { + AccountEntry::Add(data) if !removed[i] => Some(data.clone()), + _ => None, + }) + .collect() + } +} + +/// Which entries have been tombstoned, or the `Remove` that broke the log. +/// `index < position` means every target was already seen: single-pass. +fn removed_flags(entries: &[AccountEntry]) -> Result, AccountLogError> { + let mut removed = vec![false; entries.len()]; + for (position, entry) in entries.iter().enumerate() { + let AccountEntry::Remove { index } = entry else { + continue; + }; + let target = *index as usize; + let targets_live_add = target < position + && !removed[target] + && matches!(entries[target], AccountEntry::Add(_)); + if !targets_live_add { + return Err(AccountLogError::Malformed(format!( + "remove at position {position} does not point at an earlier live add ({index})" + ))); + } + removed[target] = true; + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(byte: u8) -> AccountEntry { + AccountEntry::Add(EntryData::Ed25519Key([byte; 32])) + } + + /// Replay applies tombstones and preserves add order. + #[test] + fn live_entries_applies_removes() { + let log = AccountLog::new(vec![ + key(1), + key(3), + AccountEntry::Remove { index: 0 }, + key(2), + ]) + .unwrap(); + assert_eq!( + log.live_entries(), + vec![ + EntryData::Ed25519Key([3; 32]), + EntryData::Ed25519Key([2; 32]), + ] + ); + } + + /// Every malformed remove rejects the whole log: forward and self + /// references, removing a remove, and removing twice. + #[test] + fn new_rejects_invalid_removes() { + let dangling = vec![key(1), AccountEntry::Remove { index: 7 }]; + let self_ref = vec![AccountEntry::Remove { index: 0 }]; + let of_remove = vec![ + key(1), + AccountEntry::Remove { index: 0 }, + AccountEntry::Remove { index: 1 }, + ]; + let twice = vec![ + key(1), + AccountEntry::Remove { index: 0 }, + AccountEntry::Remove { index: 0 }, + ]; + for entries in [dangling, self_ref, of_remove, twice] { + assert!(matches!( + AccountLog::new(entries), + Err(AccountLogError::Malformed(m)) if m.contains("remove at position") + )); + } + } +} diff --git a/core/account/src/addr.rs b/core/account/src/addr.rs new file mode 100644 index 00000000..d34e1cdc --- /dev/null +++ b/core/account/src/addr.rs @@ -0,0 +1,71 @@ +use std::fmt; + +use crypto::Ed25519VerifyingKey; + +use crate::error::AccountError; + +/// A routable representation of an account +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AccountAddr { + pubkey: Ed25519VerifyingKey, +} + +impl AccountAddr { + pub fn to_bytes(&self) -> &[u8] { + self.pubkey.as_ref() + } + + /// The verifying key this address wraps — what signatures are checked under. + pub(crate) fn verifying_key(&self) -> &Ed25519VerifyingKey { + &self.pubkey + } +} + +/// Displays as the hex of the account verifying key — the same form the +/// directory and keypackage registry use for ids. +impl fmt::Display for AccountAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.pubkey.as_ref())) + } +} + +impl From<&Ed25519VerifyingKey> for AccountAddr { + fn from(value: &Ed25519VerifyingKey) -> Self { + Self { + pubkey: value.clone(), + } + } +} + +/// Not every byte string is an address: exactly 32 bytes forming a valid +/// Ed25519 key. +impl TryFrom<&[u8]> for AccountAddr { + type Error = AccountError; + + fn try_from(value: &[u8]) -> Result { + let bytes: [u8; 32] = value.try_into().map_err(|_| AccountError::InvalidAddress)?; + let pubkey = + Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| AccountError::InvalidAddress)?; + Ok(Self { pubkey }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto::Ed25519SigningKey; + + /// Display is the hex of the key; TryFrom round-trips the bytes. + #[test] + fn display_and_try_from_roundtrip() { + let key = Ed25519SigningKey::generate().verifying_key(); + let addr = AccountAddr::from(&key); + assert_eq!(addr.to_string(), hex::encode(key.as_ref())); + assert_eq!(AccountAddr::try_from(addr.to_bytes()).unwrap(), addr); + } + + #[test] + fn try_from_rejects_wrong_length() { + assert!(AccountAddr::try_from(&[0u8; 31][..]).is_err()); + } +} diff --git a/core/account/src/codec.rs b/core/account/src/codec.rs new file mode 100644 index 00000000..57281130 --- /dev/null +++ b/core/account/src/codec.rs @@ -0,0 +1,405 @@ +//! Wire format for [`EncodedAccountLog`]: the canonical byte encoding of an +//! [`AccountLog`]. Encoding and decoding live together so they cannot drift +//! apart. The bytes are opaque to the server except for the fixed-offset +//! header the extension check reads. + +use crate::AccountAddr; +use crate::account_log::{ + AccountEntry, AccountLog, EncodedAccountLog, EntryData, SignedAccountLog, +}; +use crate::error::AccountLogError; + +/// Domain-separation tag, prepended to every signed payload: +/// +/// ```text +/// logos:accounts:\0 +/// ``` +/// +/// Binds the signature to this exact purpose; bump `` on layout change. +pub const ACCOUNT_LOG_DOMAIN: &[u8] = b"logos:accounts:1\0"; + +/// [`ACCOUNT_LOG_DOMAIN`] without the version segment. +const DOMAIN_STEM: &[u8] = b"logos:accounts:"; + +// Entry wire tags. One tag byte determines exactly how many bytes follow, so +// every byte string parses one way. +const TAG_ADD: u8 = 1; +const TAG_REMOVE: u8 = 2; +const DATA_ED25519: u8 = 1; + +/// Header bytes after the domain prefix: the entry count (u32 LE). The count +/// doubles as the freshness marker; u32 so no plausible client bug can +/// exhaust it (a u16 could be burned by a publish loop, and the counter has +/// no reset mechanism). +const HEADER: usize = 4; + +impl AccountLog { + /// Canonical binary encoding — the bytes that are both signed and + /// transmitted: + /// + /// ```text + /// domain : ACCOUNT_LOG_DOMAIN (constant prefix incl. version, NUL-terminated) + /// count : u32 LE (4 bytes) — number of entries that follow + /// entries : count entries, each: + /// 0x01 0x01 <32 bytes> Add(Ed25519Key) + /// 0x02 Remove + /// ``` + /// + /// The account key is *not* embedded: the account is identified + /// out-of-band by the account verifying key the caller requests, and + /// [`verify_log`] checks the signature under that key — so a log for one + /// account cannot be passed off as another's. + pub fn encode(&self) -> EncodedAccountLog { + let entries = self.entries(); + let mut out = Vec::with_capacity(ACCOUNT_LOG_DOMAIN.len() + HEADER + entries.len() * 34); + out.extend_from_slice(ACCOUNT_LOG_DOMAIN); + out.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for entry in entries { + match entry { + AccountEntry::Add(EntryData::Ed25519Key(key)) => { + out.push(TAG_ADD); + out.push(DATA_ED25519); + out.extend_from_slice(key); + } + AccountEntry::Remove { index } => { + out.push(TAG_REMOVE); + out.extend_from_slice(&index.to_le_bytes()); + } + } + } + EncodedAccountLog(out) + } +} + +impl EncodedAccountLog { + /// Validate received bytes: checks the domain prefix and version, parses + /// exactly the declared number of entries with no bytes left over, and + /// validates the log itself via [`AccountLog::new`]. + pub fn parse(bytes: Vec) -> Result { + AccountLog::new(decode_entries(&bytes)?)?; + Ok(Self(bytes)) + } + + /// Decode. Cannot fail: construction validated the bytes. + pub fn decode(&self) -> AccountLog { + AccountLog::new(decode_entries(&self.0).expect("validated at construction")) + .expect("validated at construction") + } + + /// The exact bytes that are signed and transmitted. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// The entry count from the header — the freshness marker. + fn count(&self) -> u32 { + let at = ACCOUNT_LOG_DOMAIN.len(); + u32::from_le_bytes(self.0[at..at + 4].try_into().expect("4 bytes")) + } + + /// The entry bytes after the header — the region the extension check + /// compares. + fn entry_bytes(&self) -> &[u8] { + &self.0[ACCOUNT_LOG_DOMAIN.len() + HEADER..] + } +} + +/// Shorthand for the reject-only decode failures. +fn malformed(detail: impl Into) -> AccountLogError { + AccountLogError::Malformed(detail.into()) +} + +/// Decoder behind [`EncodedAccountLog::parse`] and [`EncodedAccountLog::decode`]. +fn decode_entries(payload: &[u8]) -> Result, AccountLogError> { + let payload = match payload.strip_prefix(ACCOUNT_LOG_DOMAIN) { + Some(rest) => rest, + None => return Err(domain_error(payload)), + }; + if payload.len() < HEADER { + return Err(malformed("payload shorter than its declared layout")); + } + let count = u32::from_le_bytes(payload[..4].try_into().expect("4 bytes")) as usize; + + let mut body = &payload[HEADER..]; + let mut entries = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + let (entry, rest) = decode_entry(body)?; + entries.push(entry); + body = rest; + } + if !body.is_empty() { + return Err(malformed("payload has bytes past its declared entries")); + } + Ok(entries) +} + +/// Parse one entry off the front of `body`, returning it and the rest. +fn decode_entry(body: &[u8]) -> Result<(AccountEntry, &[u8]), AccountLogError> { + let (&tag, body) = body + .split_first() + .ok_or_else(|| malformed("payload shorter than its declared layout"))?; + match tag { + TAG_ADD => { + let (&data_tag, body) = body + .split_first() + .ok_or_else(|| malformed("payload shorter than its declared layout"))?; + match data_tag { + DATA_ED25519 => { + let (key, rest) = split_at_checked(body, 32)?; + let key = key.try_into().expect("split yields 32 bytes"); + Ok((AccountEntry::Add(EntryData::Ed25519Key(key)), rest)) + } + other => Err(malformed(format!("unknown entry tag {other}"))), + } + } + TAG_REMOVE => { + let (index, rest) = split_at_checked(body, 4)?; + let index = u32::from_le_bytes(index.try_into().expect("4 bytes")); + Ok((AccountEntry::Remove { index }, rest)) + } + other => Err(malformed(format!("unknown entry tag {other}"))), + } +} + +/// Classify a payload that failed the domain check: our stem with a different +/// version segment, or a foreign domain altogether. +fn domain_error(payload: &[u8]) -> AccountLogError { + let Some(rest) = payload.strip_prefix(DOMAIN_STEM) else { + return malformed("payload is missing the account-log domain prefix"); + }; + match rest.iter().take(16).position(|&b| b == 0) { + Some(end) => AccountLogError::Version(String::from_utf8_lossy(&rest[..end]).into_owned()), + None => malformed("payload is missing the account-log domain prefix"), + } +} + +/// `split_at` that reports a truncated payload instead of panicking. +fn split_at_checked(body: &[u8], mid: usize) -> Result<(&[u8], &[u8]), AccountLogError> { + if body.len() < mid { + return Err(malformed("payload shorter than its declared layout")); + } + Ok(body.split_at(mid)) +} + +/// Verify the account signature over the exact payload bytes, returning the +/// decoded log. +/// +/// Verifying under the *requested* account key is what binds the log to that +/// account: another account's validly-signed log won't verify under this key, +/// so an untrusted server cannot substitute one. +pub fn verify_log( + expected_account: &AccountAddr, + log: &SignedAccountLog, +) -> Result { + expected_account + .verifying_key() + .verify(log.payload.as_bytes(), &log.signature) + .map_err(|_| AccountLogError::SignatureInvalid)?; + Ok(log.payload.decode()) +} + +/// Check that `new` strictly extends `old`: strictly more entries, and the old +/// entry bytes are a prefix of the new ones. (The count field itself changes +/// between versions, so the check is over the entry region, not the whole +/// payload.) +/// +/// The server runs this on publish to refuse stale or rewritten logs, and +/// consumers run it against the last log they saw as defence in depth. It +/// compares bytes, so the server needs no knowledge of entry semantics. +pub fn verify_extension( + old: &EncodedAccountLog, + new: &EncodedAccountLog, +) -> Result<(), AccountLogError> { + if new.count() <= old.count() { + return Err(AccountLogError::Stale); + } + if !new.entry_bytes().starts_with(old.entry_bytes()) { + return Err(AccountLogError::Forked); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto::Ed25519SigningKey; + + fn key(byte: u8) -> AccountEntry { + AccountEntry::Add(EntryData::Ed25519Key([byte; 32])) + } + + fn make_log(entries: Vec) -> AccountLog { + AccountLog::new(entries).unwrap() + } + + /// encode → decode round-trips, and parse accepts encode's bytes, + /// including the empty log and every variant. + #[test] + fn payload_roundtrips() { + let log = make_log(vec![ + key(1), + key(3), + AccountEntry::Remove { index: 0 }, + key(2), + ]); + let payload = log.encode(); + assert_eq!(payload.decode(), log); + assert_eq!( + EncodedAccountLog::parse(payload.as_bytes().to_vec()).unwrap(), + payload + ); + + // Empty log is valid (an account with no entries yet). + assert!(make_log(vec![]).encode().decode().entries().is_empty()); + } + + #[test] + fn parse_rejects_short_and_truncated() { + // A domain-prefixed payload too short to hold the header. + let mut short = ACCOUNT_LOG_DOMAIN.to_vec(); + short.extend_from_slice(&[0u8; 3]); + assert!(matches!( + EncodedAccountLog::parse(short), + Err(AccountLogError::Malformed(_)) + )); + + // Drop a key byte: the last entry no longer fits. + let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec(); + bytes.pop(); + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Malformed(_)) + )); + } + + #[test] + fn parse_rejects_trailing_bytes() { + let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec(); + bytes.push(0); + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Malformed(m)) if m.contains("past its declared") + )); + } + + #[test] + fn parse_rejects_missing_domain() { + let payload = make_log(vec![]).encode(); + let without_domain = payload.as_bytes()[ACCOUNT_LOG_DOMAIN.len()..].to_vec(); + assert!(matches!( + EncodedAccountLog::parse(without_domain), + Err(AccountLogError::Malformed(m)) if m.contains("domain") + )); + } + + #[test] + fn parse_rejects_bad_version_and_tag() { + let mut bytes = make_log(vec![]).encode().as_bytes().to_vec(); + bytes[ACCOUNT_LOG_DOMAIN.len() - 2] = b'9'; // the version character + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Version(v)) if v == "9" + )); + + let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec(); + bytes[ACCOUNT_LOG_DOMAIN.len() + HEADER] = 77; // first entry's tag byte + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Malformed(m)) if m.contains("tag 77") + )); + } + + /// Well-formed bytes carrying an invalid log (a self-referencing remove) + /// are rejected at parse: broken logs never get past the boundary. Such + /// bytes cannot be produced through the API, so they are handcrafted. + #[test] + fn parse_rejects_invalid_log() { + let mut bytes = ACCOUNT_LOG_DOMAIN.to_vec(); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.push(TAG_REMOVE); + bytes.extend_from_slice(&0u32.to_le_bytes()); // Remove{0} at position 0 + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Malformed(m)) if m.contains("remove at position") + )); + } + + /// Full happy path: sign with the account key, verify under the account key. + #[test] + fn verify_accepts_well_formed_log() { + let account_key = Ed25519SigningKey::generate(); + let addr = AccountAddr::from(&account_key.verifying_key()); + let log = make_log(vec![key(1), key(2)]); + + let payload = log.encode(); + let signed = SignedAccountLog { + signature: account_key.sign(payload.as_bytes()), + payload, + }; + + assert_eq!(verify_log(&addr, &signed).unwrap(), log); + } + + /// A log validly signed by account A, served as the answer to a query for + /// account B, fails: B's key does not verify A's signature. This is the + /// anti-substitution guarantee. + #[test] + fn verify_rejects_wrong_account() { + let account_key = Ed25519SigningKey::generate(); + let payload = make_log(vec![]).encode(); + let signed = SignedAccountLog { + signature: account_key.sign(payload.as_bytes()), + payload, + }; + + let other = AccountAddr::from(&Ed25519SigningKey::generate().verifying_key()); + assert!(matches!( + verify_log(&other, &signed), + Err(AccountLogError::SignatureInvalid) + )); + } + + /// A signature over one entry list does not verify another. + #[test] + fn verify_rejects_swapped_payload() { + let account_key = Ed25519SigningKey::generate(); + let addr = AccountAddr::from(&account_key.verifying_key()); + + let signature = account_key.sign(make_log(vec![key(1)]).encode().as_bytes()); + let signed = SignedAccountLog { + payload: make_log(vec![key(2)]).encode(), + signature, + }; + assert!(matches!( + verify_log(&addr, &signed), + Err(AccountLogError::SignatureInvalid) + )); + } + + /// Appending entries is an extension; anything else is stale or a fork. + #[test] + fn extension_accepts_appends_only() { + let old = make_log(vec![key(1)]).encode(); + let new = make_log(vec![key(1), key(2)]).encode(); + verify_extension(&old, &new).unwrap(); + + // Same length: stale, even with identical contents. + assert!(matches!( + verify_extension(&old, &old), + Err(AccountLogError::Stale) + )); + + // Shrinking: stale. + assert!(matches!( + verify_extension(&new, &old), + Err(AccountLogError::Stale) + )); + + // Longer but rewrites entry 0: fork. + let fork = make_log(vec![key(3), key(2)]).encode(); + assert!(matches!( + verify_extension(&old, &fork), + Err(AccountLogError::Forked) + )); + } +} diff --git a/core/account/src/error.rs b/core/account/src/error.rs new file mode 100644 index 00000000..a3404ea1 --- /dev/null +++ b/core/account/src/error.rs @@ -0,0 +1,32 @@ +pub use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AccountError { + #[error("Generic: {0}")] + Generic(String), + #[error("No account entry for id: {0} ")] + MissingEntry(String), + #[error("invalid account address")] + InvalidAddress, + #[error(transparent)] + Log(#[from] AccountLogError), +} + +/// Failures decoding, verifying, or replaying an account log. Variants are +/// the distinctions callers act on; everything else is message detail. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AccountLogError { + /// The bytes or entries are not a valid log. Detail is diagnostic only — + /// every malformed log is handled the same way: rejected. + #[error("malformed log: {0}")] + Malformed(String), + #[error("unsupported log version {0}")] + Version(String), + #[error("account signature verification failed")] + SignatureInvalid, + #[error("stale: log does not extend the stored one")] + Stale, + #[error("fork: log rewrites history instead of extending it")] + Forked, +} diff --git a/core/account/src/lib.rs b/core/account/src/lib.rs index 1b90c8c2..e3d8f70d 100644 --- a/core/account/src/lib.rs +++ b/core/account/src/lib.rs @@ -1,4 +1,26 @@ +//! Account identity and the signed account log. +//! +//! An account is known by its [`AccountAddr`], an opaque routable id. The +//! account endorses device keys and data by appending to an [`AccountLog`], +//! signed whole on every update and verifiable against the account's address +//! — see that module for the design and its invariants. +//! +//! Applications read account state through [`AccountRegistry`]. + +#[cfg(feature = "dev")] +mod account; +mod account_log; +mod addr; +mod codec; mod directory; +mod error; + +use crypto::Ed25519VerifyingKey; + +pub use account_log::{AccountEntry, AccountLog, EncodedAccountLog, EntryData, SignedAccountLog}; +pub use addr::AccountAddr; +pub use codec::{ACCOUNT_LOG_DOMAIN, verify_extension, verify_log}; +pub use error::{AccountError, AccountLogError}; pub use directory::{ AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, DeviceSet, Lamport, @@ -7,7 +29,27 @@ pub use directory::{ }; #[cfg(feature = "dev")] -mod account; +pub use account::{TestAccountService, TestLogosAccount}; -#[cfg(feature = "dev")] -pub use account::{AddDelegateSignerError, TestLogosAccount}; +/// What applications may ask about any account. +pub trait AccountRegistry { + type Error: std::fmt::Display + std::fmt::Debug; + + /// Keys currently endorsed by `addr`. `Ok(None)`: account never published. + fn endorsed_ed25519_keys( + &self, + addr: &AccountAddr, + ) -> Result>, Self::Error>; + + /// Is `signer` currently endorsed by `addr`? Provided — one derivation, + /// so implementations cannot diverge on what "endorsed" means. + fn is_ed25519_endorsed( + &self, + signer: &Ed25519VerifyingKey, + addr: &AccountAddr, + ) -> Result { + Ok(self + .endorsed_ed25519_keys(addr)? + .is_some_and(|keys| keys.contains(signer))) + } +} diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index 2d016f31..f4589e70 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -2,6 +2,7 @@ mod direct_v1; pub mod group_v1; mod group_v2; pub mod mls_extensions; +mod mls_utils; pub use crate::errors::ChatError; use crate::outcomes::ConvoOutcome; @@ -11,6 +12,7 @@ use crate::types::ConvoMetadata; pub use direct_v1::DirectV1Convo; pub use group_v1::GroupV1Convo; pub use group_v2::{GroupV2Clock, GroupV2Convo}; +pub use mls_utils::UnverifiedSender; use shared_traits::IdentIdRef; pub type ConversationId = String; @@ -38,7 +40,7 @@ pub(crate) trait Convo: Identified + Send { /// Each current member's MLS leaf-credential content (hex-encoded), self /// included. - fn members(&self) -> Result>, ChatError>; + fn members(&self) -> Result, ChatError>; } /// Group-only operations. @@ -53,7 +55,7 @@ pub(crate) trait GroupConvo: Convo + std::fmt::Debug + S /// yet, in the same encoding as [`Self::members`]. Covers only invites /// [`Self::add_member`] made here, and is empty for a conversation kind /// whose add takes effect within that call. - fn pending_members(&self) -> Result>, ChatError>; + fn pending_members(&self) -> Result, ChatError>; // All GroupConvos MUST return ConvoMetadata // the return type is Option<_> to support legacy ConvoTypes which // are being phased out. diff --git a/core/conversations/src/conversation/direct_v1.rs b/core/conversations/src/conversation/direct_v1.rs index b1cf04ad..e92c0ec6 100644 --- a/core/conversations/src/conversation/direct_v1.rs +++ b/core/conversations/src/conversation/direct_v1.rs @@ -2,7 +2,7 @@ use chat_proto::logoschat::encryption::EncryptedPayload; use shared_traits::IdentIdRef; use crate::{ - ChatError, ExternalServices, + ChatError, ExternalServices, UnverifiedSender, conversation::{ConversationIdRef, Convo, GroupConvo, GroupV1Convo, Identified}, service_context::ServiceContext, }; @@ -62,7 +62,7 @@ where self.inner_group.wakeup(service_ctx) } - fn members(&self) -> Result>, ChatError> { + fn members(&self) -> Result, ChatError> { Convo::::members(&self.inner_group) } } diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index d275b2fa..9a211558 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -13,6 +13,7 @@ use std::collections::VecDeque; use tracing::debug; use crate::conversation::ConversationIdRef; +use crate::conversation::mls_utils::{UnverifiedSender, signer_for_sender}; use crate::inbox_v2::MlsProvider; use crate::service_context::{ExternalServices, ServiceContext}; @@ -263,6 +264,9 @@ impl Convo for GroupV1Convo { .process_message(&cx.mls_provider, protocol_message) .map_err(ChatError::generic)?; + // Sender Id is not validated, the AuthService/Client is responsible for validating that the credential + // is valid for the sender + let sender_id = signer_for_sender(&self.mls_group, &processed)?; let cred_bytes = processed.credential().serialized_content().to_vec(); let content = match processed.into_content() { @@ -271,6 +275,7 @@ impl Convo for GroupV1Convo { cx.causal.on_receive(&self.convo_id, &reliable); Some(Content { bytes: reliable.content.to_vec(), + sender_id, encoded_credential: cred_bytes, }) } @@ -296,11 +301,14 @@ impl Convo for GroupV1Convo { Ok(ConvoOutcome::empty(self.id().to_string())) } - fn members(&self) -> Result>, ChatError> { + fn members(&self) -> Result, ChatError> { Ok(self .mls_group .members() - .map(|m| m.credential.serialized_content().to_vec()) + .map(|m| UnverifiedSender { + signer_id: m.signature_key.into(), + cred: m.credential.serialized_content().to_vec(), + }) .collect()) } } @@ -354,7 +362,7 @@ impl GroupConvo for GroupV1Convo { /// Always empty: `add_member` merges its own commit, so an added member is /// on the roster by the time the call returns. - fn pending_members(&self) -> Result>, ChatError> { + fn pending_members(&self) -> Result, ChatError> { Ok(Vec::new()) } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index c82c5ae7..6af8edca 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -25,13 +25,13 @@ use openmls::prelude::tls_codec::Deserialize as _; use openmls::prelude::{KeyPackageIn, OpenMlsProvider as _, ProtocolVersion}; use openmls_traits::crypto::OpenMlsCrypto; use prost::Message; -use shared_traits::{IdentId, IdentIdRef}; +use shared_traits::{IdentId, IdentIdRef, SignerId}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::{info, instrument}; use crate::IdentityProvider; -use crate::conversation::{ConversationIdRef, ExternalServices, ServiceContext}; +use crate::conversation::{ConversationIdRef, ExternalServices, ServiceContext, UnverifiedSender}; use crate::{ ConvoOutcome, DeliveryService, RegistrationService, conversation::{ChatError, Convo, GroupConvo, Identified}, @@ -292,13 +292,22 @@ where Ok(self.outcome_from_events(&events)) } - fn members(&self) -> Result>, ChatError> { + fn members(&self) -> Result, ChatError> { // Guarantee the local member is listed so callers see the full roster. let mut members = self.conversation.members()?; let self_id = self.conversation.member_id_bytes().to_vec(); if !members.contains(&self_id) { members.push(self_id); } + + let members = members + .into_iter() + .map(|cred| UnverifiedSender { + // TODO: (!) Replace is actual SenderId. + signer_id: SignerId::from(b"".as_slice()), + cred, + }) + .collect(); Ok(members) } } @@ -386,11 +395,17 @@ where result.and(flushed) } - fn pending_members(&self) -> Result>, ChatError> { + fn pending_members(&self) -> Result, ChatError> { + // `pending_invites` records each joiner as `(member_id, signer_id)`, + // where `member_id` is the joiner's leaf credential content — the same + // `cred` bytes a committed member reports. Ok(self .pending_invites .iter() - .map(|(member_id, _)| member_id.clone()) + .map(|(member_id, signer_id)| UnverifiedSender { + signer_id: SignerId::from(signer_id.clone().into_bytes()), + cred: member_id.clone(), + }) .collect()) } @@ -479,7 +494,8 @@ impl GroupV2Convo { payload: Some(app_message::Payload::ConversationMessage(cm)), }) => Some(Content { bytes: cm.message.clone(), - encoded_credential: cm.sender.clone(), + sender_id: cm.sender.as_slice().into(), + encoded_credential: cm.sender_credential.clone(), }), _ => None, }); diff --git a/core/conversations/src/conversation/mls_utils.rs b/core/conversations/src/conversation/mls_utils.rs new file mode 100644 index 00000000..16ca194e --- /dev/null +++ b/core/conversations/src/conversation/mls_utils.rs @@ -0,0 +1,48 @@ +use openmls::{ + credentials::CredentialType, + framing::{ProcessedMessage, Sender}, + group::{Member, MlsGroup}, +}; +use tracing::warn; + +use crate::{ChatError, SignerId}; + +pub fn signer_for_sender( + mls_group: &MlsGroup, + processed: &ProcessedMessage, +) -> Result { + // The signature key openmls just verified this message under. + let sender_sig_key: Vec = match processed.sender() { + Sender::Member(leaf_index) => { + mls_group + .member_at(*leaf_index) + .ok_or_else(|| ChatError::generic("sender leaf not in tree"))? + .signature_key + } + // Application/private messages always come from a Member; anything else + // here is a protocol violation. + other => { + return Err(ChatError::generic(format!("unexpected sender: {other:?}"))); + } + }; + Ok(sender_sig_key.into()) +} + +#[derive(Debug, Clone)] +pub struct UnverifiedSender { + pub signer_id: SignerId, + pub cred: Vec, +} + +impl From for UnverifiedSender { + fn from(value: Member) -> Self { + if CredentialType::Basic != value.credential.credential_type() { + warn!(credtype = ?value.credential, "Incorrect credentialType"); + }; + + let cred = value.credential.serialized_content().to_vec(); + let signer_id = SignerId::from(value.signature_key); + + Self { signer_id, cred } + } +} diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 6511f4cf..af208bc3 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -1,6 +1,6 @@ use crate::causal_history::{CausalHistoryStore, MissingMessage}; use crate::conversation::{ - ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, + ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, UnverifiedSender, }; use crate::service_context::{ExternalServices, ServiceContext}; use crate::types::ConvoMetadata; @@ -281,7 +281,7 @@ impl<'a, S: ExternalServices + 'static> Core { /// Each member's MLS leaf-credential content (hex-encoded), for a direct /// conversation as for a group. - pub fn group_members(&mut self, convo_id: &str) -> Result>, ChatError> { + pub fn group_members(&mut self, convo_id: &str) -> Result, ChatError> { let convo = self .cached_convos .get(convo_id) @@ -293,7 +293,10 @@ impl<'a, S: ExternalServices + 'static> Core { /// Each member invited here and still awaiting the group's commit, in the /// same encoding as [`Self::group_members`]. A direct conversation has no /// pending members and reports none. - pub fn group_pending_members(&mut self, convo_id: &str) -> Result>, ChatError> { + pub fn group_pending_members( + &mut self, + convo_id: &str, + ) -> Result, ChatError> { let convo = self .cached_convos .get(convo_id) @@ -534,7 +537,7 @@ impl Convo for ConvoTypeOwned { } } - fn members(&self) -> Result>, ChatError> { + fn members(&self) -> Result, ChatError> { match self { ConvoTypeOwned::Group(group_convo) => group_convo.members(), ConvoTypeOwned::Direct(convo) => convo.members(), diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index 4dadf8d7..a7efbd2c 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -13,7 +13,7 @@ mod utils; pub use causal_history::{Frontier, MissingMessage}; pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; -pub use conversation::GroupV2Clock; +pub use conversation::{GroupV2Clock, UnverifiedSender}; 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 @@ -29,7 +29,9 @@ pub use outcomes::{ }; pub use service_context::ExternalServices; pub use service_traits::{DeliveryService, RegistrationService, WakeupService}; -pub use shared_traits::{IdentId, IdentIdRef, IdentityProvider}; +pub use shared_traits::{ + AuthResult, AuthVerifyService, IdentId, IdentIdRef, IdentityProvider, SignerId, +}; pub use storage::{ChatStore, ConversationKind}; pub use types::{AddressedEnvelope, ConvoMetadata}; pub use utils::{hex_trunc, trunc}; diff --git a/core/conversations/src/outcomes.rs b/core/conversations/src/outcomes.rs index 18877e7c..33b4b4f4 100644 --- a/core/conversations/src/outcomes.rs +++ b/core/conversations/src/outcomes.rs @@ -8,13 +8,13 @@ use storage::ConversationKind; +use crate::SignerId; use crate::conversation::ConversationId; #[derive(Debug, Clone)] pub struct Content { pub bytes: Vec, - /// Hex-encoded [`DelegateCredential`] of the sender, if present in the message. - /// Empty when the sender did not attach a credential. + pub sender_id: SignerId, pub encoded_credential: Vec, } diff --git a/core/integration_tests_core/src/test_client.rs b/core/integration_tests_core/src/test_client.rs index c13fe1ec..dfec48ff 100644 --- a/core/integration_tests_core/src/test_client.rs +++ b/core/integration_tests_core/src/test_client.rs @@ -22,7 +22,6 @@ const RAYA: usize = 1; const PAX: usize = 2; const MIRA: usize = 3; -// type ClientType = CoreClient; type ClientType = Core<(TestIdent, LocalBroadcaster, EphemeralRegistry, WP, MemStore)>; #[derive(Debug)] diff --git a/core/integration_tests_core/tests/causal_history.rs b/core/integration_tests_core/tests/causal_history.rs index 434fad00..3cf9ffb2 100644 --- a/core/integration_tests_core/tests/causal_history.rs +++ b/core/integration_tests_core/tests/causal_history.rs @@ -16,26 +16,20 @@ impl WakeupService for NoopWakeupService { fn wakeup_in(&mut self, _: std::time::Duration, _: libchat::ConversationId) {} } +type Services = ( + TestIdent, + LocalBroadcaster, + EphemeralRegistry, + NoopWakeupService, + MemStore, +); + struct Client { - inner: Core<( - TestIdent, - LocalBroadcaster, - EphemeralRegistry, - NoopWakeupService, - MemStore, - )>, + inner: Core, } impl Client { - fn init( - core: Core<( - TestIdent, - LocalBroadcaster, - EphemeralRegistry, - NoopWakeupService, - MemStore, - )>, - ) -> Self { + fn init(core: Core) -> Self { Client { inner: core } } @@ -59,13 +53,7 @@ impl Client { } impl Deref for Client { - type Target = Core<( - TestIdent, - LocalBroadcaster, - EphemeralRegistry, - NoopWakeupService, - MemStore, - )>; + type Target = Core; fn deref(&self) -> &Self::Target { &self.inner } diff --git a/core/shared-traits/src/lib.rs b/core/shared-traits/src/lib.rs index 06316cb3..5bdc904c 100644 --- a/core/shared-traits/src/lib.rs +++ b/core/shared-traits/src/lib.rs @@ -27,6 +27,37 @@ impl AsRef for IdentId { } } +#[derive(Debug, Clone)] +pub struct SignerId(Vec); + +impl SignerId { + pub fn from_ed25519(key: &Ed25519VerifyingKey) -> Self { + Self(key.as_ref().to_vec()) + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl From> for SignerId { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl From<&[u8]> for SignerId { + fn from(value: &[u8]) -> Self { + Self(value.to_vec()) + } +} + +impl AsRef<[u8]> for SignerId { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + /// Represents an external Identity /// Implement this to provide an Authentication model for users/installations pub trait IdentityProvider { @@ -37,3 +68,16 @@ pub trait IdentityProvider { fn sign(&self, payload: &[u8]) -> Ed25519Signature; fn public_key(&self) -> &Ed25519VerifyingKey; } + +/// Verifies that a credential is validly bound to a signer. Implementations +/// return [`AuthResult::Valid`] only when the two are cryptographically bound. +pub trait AuthVerifyService: fmt::Debug + Clone { + fn validate(&self, signer: &[u8], credential: &[u8]) -> AuthResult; +} + +#[derive(Debug, PartialEq)] +pub enum AuthResult { + Valid, + Mismatch, + ProcessingError(String), +} diff --git a/crates/generic-chat/examples/message-exchange/main.rs b/crates/generic-chat/examples/message-exchange/main.rs index 1a404edf..2b4d7e36 100644 --- a/crates/generic-chat/examples/message-exchange/main.rs +++ b/crates/generic-chat/examples/message-exchange/main.rs @@ -1,34 +1,38 @@ use components::EphemeralRegistry; use logos_account::TestLogosAccount; -use logos_generic_chat::{ChatClientBuilder, DelegateSigner, Event, InProcessDelivery, MessageBus}; +use logos_generic_chat::{ + ChatClientBuilder, DelegateSigner, Event, InProcessDelivery, LogosAuthVerifier, MessageBus, +}; use std::time::Duration; fn main() { let bus = MessageBus::default(); let mut reg = EphemeralRegistry::new(); - // 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(); + // Mint two accounts, each endorsing a delegate signer, so a peer can resolve + // an account address to its device. + let mut saro_account = TestLogosAccount::new(); let saro_delegate = DelegateSigner::random(); saro_account - .add_delegate_signer(&mut reg, saro_delegate.public_key()) + .endorse_ed25519_signer(&mut reg, saro_delegate.public_key()) .unwrap(); - let raya_account = TestLogosAccount::new(); + let mut raya_account = TestLogosAccount::new(); let raya_delegate = DelegateSigner::random(); raya_account - .add_delegate_signer(&mut reg, raya_delegate.public_key()) + .endorse_ed25519_signer(&mut reg, raya_delegate.public_key()) .unwrap(); - let (mut saro, saro_events) = ChatClientBuilder::new(saro_account.address()) + let (mut saro, saro_events) = ChatClientBuilder::new(saro_account.address().to_bytes()) + .auth(LogosAuthVerifier::new()) .ident(saro_delegate) .transport(InProcessDelivery::new(bus.clone())) .registration(reg.clone()) .build() .unwrap(); - let (mut raya, raya_events) = ChatClientBuilder::new(raya_account.address()) + let (mut raya, raya_events) = ChatClientBuilder::new(raya_account.address().to_bytes()) + .auth(LogosAuthVerifier::new()) .ident(raya_delegate) .transport(InProcessDelivery::new(bus)) .registration(reg) diff --git a/crates/generic-chat/src/builder.rs b/crates/generic-chat/src/builder.rs index a1c8eac1..bc5db77a 100644 --- a/crates/generic-chat/src/builder.rs +++ b/crates/generic-chat/src/builder.rs @@ -1,6 +1,8 @@ use components::EphemeralRegistry; use crossbeam_channel::Receiver; -use libchat::{ChatError, ChatStorage, GroupV2Config, RegistrationService, StorageConfig}; +use libchat::{ + AuthVerifyService, ChatError, ChatStorage, GroupV2Config, RegistrationService, StorageConfig, +}; use logos_account::AccountDirectory; use storage::ChatStore; @@ -14,9 +16,10 @@ use crate::event::Event; /// component will be filled in with a sensible default when `build()` is called. pub struct Unset; -pub struct ChatClientBuilder { +pub struct ChatClientBuilder { ident: I, - account: String, + auth: AS, + account: Vec, transport: T, registration: R, storage: S, @@ -25,13 +28,17 @@ pub struct ChatClientBuilder { impl ChatClientBuilder { /// Every client acts for an account, so the builder starts from its - /// address. It becomes the client's shareable address + /// address bytes. They become the client's shareable address /// ([`ChatClient::addr`]) and the account claim in the wire credential; /// the account must endorse the signer in the directory for peers to /// verify that claim. - pub fn new(account: impl Into) -> Self { + /// + /// A credential verifier is required before [`build`](ChatClientBuilder::build); + /// set one with [`auth`](ChatClientBuilder::auth). + pub fn new(account: impl Into>) -> Self { Self { ident: Unset, + auth: Unset, account: account.into(), transport: Unset, registration: Unset, @@ -41,10 +48,11 @@ impl ChatClientBuilder { } } -impl ChatClientBuilder { - pub fn ident(self, ident: DelegateSigner) -> ChatClientBuilder { +impl ChatClientBuilder { + pub fn ident(self, ident: DelegateSigner) -> ChatClientBuilder { ChatClientBuilder { ident, + auth: self.auth, account: self.account, transport: self.transport, registration: self.registration, @@ -53,9 +61,24 @@ impl ChatClientBuilder { } } - pub fn transport(self, transport: NT) -> ChatClientBuilder { + /// The credential verifier this client uses to bind a message signer to the + /// account named in its credential. Required before [`build`](Self::build). + pub fn auth(self, auth: NAS) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, + auth, + account: self.account, + transport: self.transport, + registration: self.registration, + storage: self.storage, + group_v2: self.group_v2, + } + } + + pub fn transport(self, transport: NT) -> ChatClientBuilder { + ChatClientBuilder { + ident: self.ident, + auth: self.auth, account: self.account, transport, registration: self.registration, @@ -64,9 +87,10 @@ impl ChatClientBuilder { } } - pub fn registration(self, registration: NR) -> ChatClientBuilder { + pub fn registration(self, registration: NR) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, + auth: self.auth, account: self.account, transport: self.transport, registration, @@ -75,9 +99,10 @@ impl ChatClientBuilder { } } - pub fn storage(self, storage: NS) -> ChatClientBuilder { + pub fn storage(self, storage: NS) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, + auth: self.auth, account: self.account, transport: self.transport, registration: self.registration, @@ -86,13 +111,17 @@ impl ChatClientBuilder { } } - pub fn storage_config(self, config: StorageConfig) -> ChatClientBuilder { + pub fn storage_config( + self, + config: StorageConfig, + ) -> ChatClientBuilder { let storage = ChatStorage::new(config) .map_err(ChatError::from) .expect("Storage config file should be valid"); ChatClientBuilder { ident: self.ident, + auth: self.auth, account: self.account, transport: self.transport, registration: self.registration, @@ -111,18 +140,20 @@ impl ChatClientBuilder { } } -type Built = Result<(ChatClient, Receiver), ClientError>; +type Built = Result<(ChatClient, Receiver), ClientError>; // All four explicitly provided. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, + self.auth, self.account, self.transport, self.registration, @@ -133,10 +164,15 @@ where } // Transport only; I, R, S all default. -impl ChatClientBuilder { - pub fn build(self) -> Built { +impl ChatClientBuilder +where + AS: AuthVerifyService + Send + 'static, + T: Transport + Send + 'static, +{ + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.auth, self.account, self.transport, EphemeralRegistry::new(), @@ -147,13 +183,15 @@ impl ChatClientBuilder { } // I and T; R and S default. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, + self.auth, self.account, self.transport, EphemeralRegistry::new(), @@ -164,14 +202,16 @@ where } // T and R; I and S default. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.auth, self.account, self.transport, self.registration, @@ -182,14 +222,16 @@ where } // T and S; I and R default. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.auth, self.account, self.transport, EphemeralRegistry::new(), @@ -200,14 +242,16 @@ where } // I, T, and R; S defaults. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, + self.auth, self.account, self.transport, self.registration, @@ -218,15 +262,17 @@ where } // T, R, and S; I defaults. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.auth, self.account, self.transport, self.registration, @@ -237,14 +283,16 @@ where } // I, T, and S; R defaults. -impl ChatClientBuilder +impl ChatClientBuilder where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, + self.auth, self.account, self.transport, EphemeralRegistry::new(), diff --git a/crates/generic-chat/src/client.rs b/crates/generic-chat/src/client.rs index f4e0df46..6dcc7b59 100644 --- a/crates/generic-chat/src/client.rs +++ b/crates/generic-chat/src/client.rs @@ -6,8 +6,9 @@ use components::{ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; use crypto::Ed25519VerifyingKey; use libchat::{ - ConversationId, ConvoMetadata, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, - IdentIdRef, InboxOutcome, PayloadOutcome, RegistrationService, + AuthResult, AuthVerifyService, ConversationId, ConvoMetadata, ConvoOutcome, Core, + DeliveryService, GroupV2Config, IdentId, IdentIdRef, InboxOutcome, PayloadOutcome, + RegistrationService, SignerId, UnverifiedSender, }; use logos_account::{AccountDirectory, resolve_device_ids}; use parking_lot::Mutex; @@ -17,8 +18,25 @@ use crate::delegate::{DelegateCredential, DelegateIdentity, DelegateSigner}; use crate::errors::ClientError; use crate::event::{Event, MessageSender}; +#[derive(Debug, Clone, Default)] +pub struct LogosAuthVerifier {} + +impl LogosAuthVerifier { + pub fn new() -> Self { + Self::default() + } +} + +impl AuthVerifyService for LogosAuthVerifier { + fn validate(&self, _signer: &[u8], _credential: &[u8]) -> AuthResult { + AuthResult::Valid + } +} + type ClientCore = Core<(DelegateIdentity, T, R, ThreadedWakeupService, S)>; -type AccountAddressRef<'a> = &'a str; +/// An account address as the client handles it: opaque bytes, interpreted only +/// where they meet the account layer. +type AccountAddressRef<'a> = &'a [u8]; type LocalSignerId = IdentId; /// A member of a group conversation's roster. @@ -36,11 +54,71 @@ type LocalSignerId = IdentId; /// never commits stays pending for the life of the conversation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroupMember { - pub account: Option, + pub account: Option>, pub local_identity: IdentId, pub pending: bool, } +/// The raw roster entry verification produces, one per MLS leaf (device): the +/// member's `signer_id`, its `cred` (the credential as MLS reports it, +/// hex-encoded), the `auth_result` of checking that credential against the auth +/// service, and whether its add is still `pending`. [`ChatClient::group_members`] +/// resolves these into [`GroupMember`]s; [`ChatClient::group_members_including_invalid`] +/// exposes them directly so a caller can see members that failed verification. +#[derive(Debug)] +pub struct MemberWithAuthResult { + pub signer_id: SignerId, + pub cred: Vec, + pub auth_result: AuthResult, + pub pending: bool, +} + +impl MemberWithAuthResult { + pub fn new(signer: UnverifiedSender, auth_result: AuthResult, pending: bool) -> Self { + Self { + signer_id: signer.signer_id, + cred: signer.cred, + auth_result, + pending, + } + } + + /// Parse this member's credential, if it is well-formed. A real MLS leaf + /// always is; `None` marks a credential MLS reported that this client can't + /// decode. + fn credential(&self) -> Option { + let ident = IdentId::new(String::from_utf8(self.cred.clone()).ok()?); + DelegateCredential::try_from(ident).ok() + } + + /// The account this member's credential claims, if any. Trustworthy only + /// when `auth_result` is `Valid`; the credential asserts it, unverified. + pub fn account_claim(&self) -> Option> { + self.credential()?.account_addr().map(<[u8]>::to_vec) + } +} + +impl From for GroupMember { + /// Resolve a verified roster entry into its public form: the account is + /// surfaced only when verification passed, so an unconfirmable member stays + /// listed by device with `account: None` rather than being hidden. + fn from(member: MemberWithAuthResult) -> Self { + let cred = member.credential(); + let local_identity = cred + .as_ref() + .map(|c| IdentId::new(hex::encode(c.delegate_id().as_ref()))) + .unwrap_or_else(|| IdentId::new(hex::encode(member.signer_id.as_bytes()))); + let account = (member.auth_result == AuthResult::Valid) + .then(|| cred.and_then(|c| c.account_addr().map(<[u8]>::to_vec))) + .flatten(); + GroupMember { + account, + local_identity, + pending: member.pending, + } + } +} + /// Metadata a caller supplies when creating a group: its shared name and /// description. Distinct from [`ConvoMetadata`], the type a conversation /// reports back — the two carry different concerns and evolve independently @@ -81,8 +159,9 @@ pub trait Transport: DeliveryService + Send + 'static { /// caller's thread: they briefly lock the core, invoke it, and return — no /// message-passing round-trip. The `Arc`/`Mutex`/threads live entirely here; /// the core never mentions threads. -pub struct ChatClient +pub struct ChatClient where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, @@ -90,6 +169,7 @@ where /// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't /// starve caller operations of the lock. core: Arc>>, + account_verify_service: AS, /// The account → device directory. On testnet the registration service /// doubles as the directory (one deployed registry serves both roles), so /// the client keeps its own clone of `R`; the core sees key packages only. @@ -97,19 +177,21 @@ where /// Dropped on `Drop` to wake the worker's `select!` and shut it down. shutdown: Option>, worker: Option>, - address: String, + address: Vec, } // -- GenericChatClient -impl ChatClient +impl ChatClient where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { pub fn new( ident: DelegateSigner, - account: String, + auth: AS, + account: Vec, mut transport: T, reg: R, storage: S, @@ -125,13 +207,16 @@ where if let Some(config) = group_v2 { core.set_group_v2_config(config); } - Ok(Self::spawn(core, directory, account, inbound, wakeup_rx)) + Ok(Self::spawn( + core, auth, directory, account, inbound, wakeup_rx, + )) } fn spawn( core: ClientCore, + auth: AS, directory: R, - address: String, + address: Vec, inbound: Receiver>, wakeup_events: Receiver, ) -> (Self, Receiver) { @@ -157,6 +242,7 @@ where ( Self { core, + account_verify_service: auth, directory, shutdown: Some(shutdown_tx), worker: Some(worker), @@ -167,7 +253,7 @@ where } /// The account address peers use to reach this client. - pub fn addr(&self) -> &str { + pub fn addr(&self) -> &[u8] { &self.address } @@ -229,16 +315,28 @@ where .map_err(Into::into) } - /// The conversation's roster, one [`GroupMember`] per account (self - /// included), for a direct conversation as for a group: committed members - /// first and this client's uncommitted invites after them, flagged - /// `pending`. An account's several devices collapse to a single entry - /// surfacing that account; a member whose account claim the directory can't - /// confirm stays on the roster individually, keyed by its device. An account - /// that is both committed and pending collapses to its committed entry. - /// Costs one directory lookup per member that claims an account, the same - /// per-member cost a received message's sender check pays. + /// The group's roster as [`GroupMember`]s (self included): one entry per + /// member device, with the account surfaced only when its credential + /// verified. Members whose add the group has not committed yet are flagged + /// `pending`. An unverifiable member is not hidden — it stays listed by + /// device with `account: None`. pub fn group_members(&mut self, convo_id: &str) -> Result, ClientError> { + Ok(self + .group_members_including_invalid(convo_id)? + .into_iter() + .map(GroupMember::from) + .collect()) + } + + /// The raw roster before resolution: every member device paired with its + /// verification result, including those that failed to verify (which + /// [`Self::group_members`] would list without an account). Committed members + /// come before pending ones; a device present in both collapses to its + /// committed entry. + pub fn group_members_including_invalid( + &mut self, + convo_id: &str, + ) -> Result, ClientError> { let (committed, pending) = { let mut core = self.core.lock(); ( @@ -247,14 +345,13 @@ where ) }; let members = committed - .iter() - .filter_map(|credential| roster_member(&self.directory, credential)) - .chain(pending.iter().filter_map(|credential| { - roster_member(&self.directory, credential).map(|member| GroupMember { - pending: true, - ..member - }) - })); + .into_iter() + .map(|sender| (sender, false)) + .chain(pending.into_iter().map(|sender| (sender, true))) + .map(|(sender, pending)| { + let auth_result = self.verify_member(&sender); + MemberWithAuthResult::new(sender, auth_result, pending) + }); Ok(dedup_members(members)) } @@ -289,7 +386,9 @@ where &self, account: AccountAddressRef, ) -> Result, ClientError> { - let account = IdentId::new(account.to_string()); + // The directory keys accounts by the hex of the account key, so the + // address bytes are encoded at that boundary. + let account = IdentId::new(hex::encode(account)); let device_ids = resolve_device_ids(&self.directory, &account) .map_err(|e| ClientError::AccountResolution(e.to_string()))?; Ok(device_ids.into_iter().map(IdentId::new).collect()) @@ -307,10 +406,16 @@ where } Ok(signers) } + + fn verify_member(&self, member: &UnverifiedSender) -> AuthResult { + self.account_verify_service + .validate(member.signer_id.as_bytes(), member.cred.as_slice()) + } } -impl Drop for ChatClient +impl Drop for ChatClient where + AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, @@ -398,9 +503,9 @@ fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory } } -/// 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()?; +/// Interpret account address bytes as an Ed25519 account verifying key. +fn account_key_from_bytes(addr: &[u8]) -> Option { + let bytes: [u8; 32] = addr.try_into().ok()?; Ed25519VerifyingKey::from_bytes(&bytes).ok() } @@ -414,7 +519,7 @@ enum SenderError { NotHex, /// Credential bytes did not decode to a delegate credential. Malformed, - /// The claimed account address is not an Ed25519 verifying key. + /// The claimed account address is not the bytes of an Ed25519 verifying key. AccountNotAKey, /// The account → device mapping is wrong or could not be confirmed: the /// device is not in the account's published set, the account published none, @@ -427,7 +532,7 @@ enum AccountClaim { /// The credential claimed no account. None, /// Confirmed: the directory lists this device under the claimed account. - Verified(IdentId), + Verified(Vec), /// An account was claimed but could not be confirmed (see [`SenderError`]). Unverified(SenderError), } @@ -459,8 +564,8 @@ fn parse_credential( let Some(account_addr) = cred.account_addr() else { return Ok((device, AccountClaim::None)); }; - let Some(account_key) = account_key_from_hex(account_addr) else { - tracing::warn!(account_addr, "account address is not a verifying key"); + let Some(account_key) = account_key_from_bytes(account_addr) else { + tracing::warn!(account_addr = %hex::encode(account_addr), "account address is not a verifying key"); return Ok(( device, AccountClaim::Unverified(SenderError::AccountNotAKey), @@ -468,10 +573,10 @@ fn parse_credential( }; let claim = match directory.fetch(&account_key) { Ok(Some(set)) if set.devices.iter().any(|d| d.as_str() == device.as_str()) => { - AccountClaim::Verified(IdentId::new(account_addr.to_string())) + AccountClaim::Verified(account_addr.to_vec()) } _ => { - tracing::warn!(account_addr, device = %device.as_str(), "account → device mapping is wrong or unconfirmable"); + tracing::warn!(account_addr = %hex::encode(account_addr), device = %device.as_str(), "account → device mapping is wrong or unconfirmable"); AccountClaim::Unverified(SenderError::Unverified) } }; @@ -505,44 +610,16 @@ fn decode_sender( } } -/// Map a group member's credential (as reported by MLS, in the same hex-encoded -/// form a message carries as its sender) to a roster entry, tolerating an -/// unconfirmable account claim by listing the device without an account. `None` -/// only when the credential cannot be parsed, which does not happen for a real -/// MLS leaf. -fn roster_member(directory: &impl AccountDirectory, encoded: &[u8]) -> Option { - let (device, claim) = parse_credential(directory, encoded).ok()?; - let account = match claim { - AccountClaim::Verified(account) => Some(account), - AccountClaim::None | AccountClaim::Unverified(_) => None, - }; - Some(GroupMember { - account, - local_identity: device, - pending: false, - }) -} - -/// The key that decides whether two roster entries are the same member: a -/// verified account, so an account's several devices count once; or, for a -/// member with no confirmed account, its device — unique per MLS leaf, so it -/// never merges with another. -fn member_key(member: &GroupMember) -> &str { - member - .account - .as_ref() - .unwrap_or(&member.local_identity) - .as_str() -} - -/// Collapse a roster to one entry per account (keeping the first-seen device as -/// the account's representative) while leaving account-less members individual, -/// order preserved. -fn dedup_members(members: impl IntoIterator) -> Vec { +/// Collapse a roster to one entry per credential (device), keeping the +/// first-seen entry, order preserved. Callers chain committed members ahead of +/// pending ones, so a device present in both keeps its committed entry. +fn dedup_members( + members: impl IntoIterator, +) -> Vec { let mut seen = HashSet::new(); members .into_iter() - .filter(|member| seen.insert(member_key(member).to_owned())) + .filter(|member| seen.insert(member.cred.clone())) .collect() } @@ -600,9 +677,10 @@ mod sender_check_tests { use libchat::IdentId; use logos_account::{DeviceSet, SignedDeviceBundle}; + use libchat::{AuthResult, SignerId}; + use super::{ - GroupMember, MessageSender, SenderError, decode_sender, dedup_members, member_key, - roster_member, + GroupMember, MemberWithAuthResult, MessageSender, SenderError, decode_sender, dedup_members, }; use crate::delegate::DelegateCredential; @@ -664,6 +742,11 @@ mod sender_check_tests { IdentId::new(hex::encode(k.as_ref())) } + /// An account address as the client carries it: the raw key bytes. + fn account_addr(k: &Ed25519VerifyingKey) -> Vec { + k.as_ref().to_vec() + } + /// The account published a device set that includes the sending device — the /// claim checks out, so the message is delivered with a verified account. #[test] @@ -671,11 +754,11 @@ mod sender_check_tests { let account = key(); let device = key(); let dir = FakeDir::with_devices(&account, &[&device]); - let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + let cred = DelegateCredential::associated(&device, account.as_ref()); assert_eq!( decode_sender(&dir, &encoded(cred)), Ok(MessageSender { - account: Some(local_id(&account)), + account: Some(account_addr(&account)), local_identity: local_id(&device), }) ); @@ -689,7 +772,7 @@ mod sender_check_tests { let endorsed = key(); let spoofer = key(); let dir = FakeDir::with_devices(&account, &[&endorsed]); - let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref())); + let cred = DelegateCredential::associated(&spoofer, account.as_ref()); assert_eq!( decode_sender(&dir, &encoded(cred)), Err(SenderError::Unverified) @@ -718,7 +801,7 @@ mod sender_check_tests { let account = key(); let device = key(); let dir = FakeDir::default(); // nothing published - let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + let cred = DelegateCredential::associated(&device, account.as_ref()); assert_eq!( decode_sender(&dir, &encoded(cred)), Err(SenderError::Unverified) @@ -735,7 +818,7 @@ mod sender_check_tests { fail: true, ..Default::default() }; - let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + let cred = DelegateCredential::associated(&device, account.as_ref()); assert_eq!( decode_sender(&dir, &encoded(cred)), Err(SenderError::Unverified) @@ -766,151 +849,130 @@ mod sender_check_tests { #[test] fn non_key_account_address_is_dropped() { let dir = FakeDir::default(); - let cred = DelegateCredential::associated(&key(), "user@example.com"); + let cred = DelegateCredential::associated(&key(), b"user@example.com"); assert_eq!( decode_sender(&dir, &encoded(cred)), Err(SenderError::AccountNotAKey) ); } - /// A verified account claim surfaces the member's account and device — the - /// same happy path as a message sender. + /// Build a raw roster entry from a credential and its verification result, + /// as `group_members` would before resolving it into a [`GroupMember`]. + fn member_entry( + cred: DelegateCredential, + auth_result: AuthResult, + pending: bool, + ) -> MemberWithAuthResult { + let signer_id = SignerId::from(cred.delegate_id().as_ref()); + MemberWithAuthResult { + signer_id, + cred: encoded(cred), + auth_result, + pending, + } + } + + /// A verified member resolves to its account and device — the credential's + /// account claim, trusted because verification passed. #[test] - fn roster_verified_member_surfaces_account() { + fn resolves_verified_member_to_account_and_device() { let account = key(); let device = key(); - let dir = FakeDir::with_devices(&account, &[&device]); - let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + let cred = DelegateCredential::associated(&device, account.as_ref()); assert_eq!( - roster_member(&dir, &encoded(cred)), - Some(GroupMember { - account: Some(local_id(&account)), + GroupMember::from(member_entry(cred, AuthResult::Valid, false)), + GroupMember { + account: Some(account_addr(&account)), local_identity: local_id(&device), pending: false, - }) + } ); } - /// Unlike a message sender, a spoofed account claim does not hide the - /// member: the device is cryptographically in the group, so it is listed - /// with no account rather than dropped. + /// A member whose credential failed verification is not hidden: it is listed + /// by device with no account. #[test] - fn roster_contradicted_claim_lists_device_without_account() { + fn resolves_unverified_member_to_device_without_account() { 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_eq!( - roster_member(&dir, &encoded(cred)), - Some(GroupMember { - account: None, - local_identity: local_id(&spoofer), - pending: false, - }) - ); - } - - /// A member whose credential claims no account is listed by device only. - #[test] - fn roster_unassociated_member_lists_device_without_account() { - let dir = FakeDir::default(); let device = key(); - let cred = DelegateCredential::unassociated(&device); + let cred = DelegateCredential::associated(&device, account.as_ref()); assert_eq!( - roster_member(&dir, &encoded(cred)), - Some(GroupMember { + GroupMember::from(member_entry(cred, AuthResult::Mismatch, false)), + GroupMember { account: None, local_identity: local_id(&device), pending: false, - }) + } ); } - /// A directory outage leaves the account unconfirmed, but the member stays - /// on the roster by device (a message would drop here). + /// A member whose credential claims no account is listed by device only, + /// even when verification passed. #[test] - fn roster_directory_outage_lists_device_without_account() { - let account = key(); + fn resolves_unassociated_member_to_device_without_account() { let device = key(); - let dir = FakeDir { - fail: true, - ..Default::default() - }; - let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + let cred = DelegateCredential::unassociated(&device); assert_eq!( - roster_member(&dir, &encoded(cred)), - Some(GroupMember { + GroupMember::from(member_entry(cred, AuthResult::Valid, false)), + GroupMember { account: None, local_identity: local_id(&device), pending: false, - }) + } ); } - /// A non-key account address can't be confirmed, so the member is listed by - /// device without an account. + /// Resolution carries the pending flag through to the public entry. #[test] - fn roster_non_key_account_lists_device_without_account() { - let dir = FakeDir::default(); + fn resolves_pending_flag_onto_the_public_entry() { let device = key(); - let cred = DelegateCredential::associated(&device, "user@example.com"); - assert_eq!( - roster_member(&dir, &encoded(cred)), - Some(GroupMember { - account: None, - local_identity: local_id(&device), - pending: false, - }) - ); + let cred = DelegateCredential::unassociated(&device); + assert!(GroupMember::from(member_entry(cred, AuthResult::Valid, true)).pending); } - /// The roster collapses an account's several devices into one entry (keeping - /// the first device seen) while leaving account-less members individual, + /// The roster collapses members that share a credential into one entry + /// (keeping the first seen) while leaving distinct credentials individual, /// order preserved. #[test] - fn dedup_collapses_account_devices_and_keeps_unknowns() { - let with_account = |account: &str, device: &str| GroupMember { - account: Some(IdentId::new(account.to_string())), - local_identity: IdentId::new(device.to_string()), - pending: false, - }; - let device_only = |device: &str| GroupMember { - account: None, - local_identity: IdentId::new(device.to_string()), + fn dedup_collapses_duplicate_credentials_and_keeps_distinct() { + let member = |cred: &str| MemberWithAuthResult { + signer_id: SignerId::from(cred.as_bytes()), + cred: cred.as_bytes().to_vec(), + auth_result: AuthResult::Valid, pending: false, }; let roster = dedup_members(vec![ - with_account("alice", "alice-dev-1"), - with_account("alice", "alice-dev-2"), - device_only("orphan-x"), - with_account("bob", "bob-dev-1"), - device_only("orphan-y"), + member("alice-dev-1"), + member("alice-dev-1"), + member("orphan-x"), + member("bob-dev-1"), + member("orphan-y"), ]); - let keys: Vec<&str> = roster.iter().map(member_key).collect(); - assert_eq!(keys, ["alice", "orphan-x", "bob", "orphan-y"]); - // Alice's collapsed entry keeps her first-seen device. - assert_eq!(roster[0].local_identity.as_str(), "alice-dev-1"); + let creds: Vec<&[u8]> = roster.iter().map(|m| m.cred.as_slice()).collect(); + assert_eq!( + creds, + [ + b"alice-dev-1".as_slice(), + b"orphan-x", + b"bob-dev-1", + b"orphan-y", + ] + ); } - /// An account that is both committed and pending collapses to its committed - /// entry: `group_members` chains committed members first, and dedup keeps - /// the first entry per account. + /// A device present in both the committed and pending lists collapses to a + /// single entry: callers chain committed members first, so dedup keeps that + /// one and the survivor is not flagged pending. #[test] fn dedup_collapses_a_pending_duplicate_into_the_committed_member() { - let committed = GroupMember { - account: Some(IdentId::new("alice")), - local_identity: IdentId::new("alice-dev-1"), - pending: false, - }; - let pending = GroupMember { - account: Some(IdentId::new("alice")), - local_identity: IdentId::new("alice-dev-2"), - pending: true, + let entry = |pending: bool| MemberWithAuthResult { + signer_id: SignerId::from(b"alice-dev-1".as_slice()), + cred: b"alice-dev-1".to_vec(), + auth_result: AuthResult::Valid, + pending, }; - assert_eq!( - dedup_members(vec![committed.clone(), pending]), - vec![committed] - ); + let roster = dedup_members(vec![entry(false), entry(true)]); + assert_eq!(roster.len(), 1); + assert!(!roster[0].pending); } } diff --git a/crates/generic-chat/src/delegate.rs b/crates/generic-chat/src/delegate.rs index a8ea88ca..6eed7a76 100644 --- a/crates/generic-chat/src/delegate.rs +++ b/crates/generic-chat/src/delegate.rs @@ -3,8 +3,6 @@ use libchat::{IdentId, IdentityProvider, trunc}; use crate::ClientError; -type AccountAddr = String; - /// A local signing identity that holds an Ed25519 keypair — the per-device /// (installation) signer. It knows nothing about accounts: the client composes /// the account association into the wire credential ([`DelegateIdentity`]). @@ -42,7 +40,7 @@ pub(crate) struct DelegateIdentity { } impl DelegateIdentity { - pub(crate) fn new(signer: DelegateSigner, account: &str) -> Self { + pub(crate) fn new(signer: DelegateSigner, account: &[u8]) -> Self { let credential = DelegateCredential::associated(signer.public_key(), account); Self { identifier: credential.into(), @@ -73,11 +71,12 @@ impl IdentityProvider for DelegateIdentity { /// /// Serialized as a TLV byte sequence prefixed with magic bytes `0x23 0x23`. /// A credential without an `account_addr` is *unassociated* — it identifies the -/// delegate key but has not yet been linked to an account. +/// delegate key but has not yet been linked to an account. The address is +/// carried as opaque bytes; only the account layer interprets them. #[derive(Debug)] pub struct DelegateCredential { delegate_id: Ed25519VerifyingKey, - account_addr: Option, + account_addr: Option>, } impl DelegateCredential { @@ -91,10 +90,10 @@ impl DelegateCredential { } } - pub fn associated(delegate: &Ed25519VerifyingKey, account: &str) -> Self { + pub fn associated(delegate: &Ed25519VerifyingKey, account: &[u8]) -> Self { Self { delegate_id: delegate.clone(), - account_addr: Some(account.to_string()), + account_addr: Some(account.to_vec()), } } @@ -105,7 +104,7 @@ impl DelegateCredential { /// 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> { + pub fn account_addr(&self) -> Option<&[u8]> { self.account_addr.as_deref() } @@ -120,13 +119,12 @@ impl DelegateCredential { data.extend_from_slice(&[Self::TAG_DELEGATE_ID, key_bytes.len() as u8]); data.extend_from_slice(key_bytes); if let Some(addr) = self.account_addr { - let addr_bytes = addr.as_bytes(); debug_assert!( - addr_bytes.len() <= 255, + addr.len() <= 255, "account_addr too large for 1-byte TLV length" ); - data.extend_from_slice(&[Self::TAG_ACCOUNT_ADDR, addr_bytes.len() as u8]); - data.extend_from_slice(addr_bytes); + data.extend_from_slice(&[Self::TAG_ACCOUNT_ADDR, addr.len() as u8]); + data.extend_from_slice(&addr); } data } @@ -167,10 +165,7 @@ impl TryFrom> for DelegateCredential { ); } DelegateCredential::TAG_ACCOUNT_ADDR => { - account_addr = Some( - String::from_utf8(v.to_vec()) - .map_err(|_| ClientError::BadlyFormedCredential)?, - ); + account_addr = Some(v.to_vec()); } _ => {} } @@ -218,7 +213,7 @@ mod tests { #[test] fn roundtrip_associated() { let key = test_key(); - let bytes = DelegateCredential::associated(&key, "user@example.com").serialize(); + let bytes = DelegateCredential::associated(&key, b"user@example.com").serialize(); let recovered: DelegateCredential = bytes.clone().try_into().unwrap(); assert_eq!(recovered.serialize(), bytes); } @@ -235,7 +230,7 @@ mod tests { #[test] fn ident_id_roundtrip_associated() { let key = test_key(); - let addr = "user@example.com"; + let addr = b"user@example.com"; let original = DelegateCredential::associated(&key, addr).serialize(); let ident_id: IdentId = DelegateCredential::associated(&key, addr).into(); let recovered: DelegateCredential = ident_id.try_into().unwrap(); @@ -245,12 +240,12 @@ mod tests { #[test] fn account_addr_preserved_across_roundtrip() { let key = test_key(); - let addr = "alice@libchat.example"; + let addr = b"alice@libchat.example"; let recovered: DelegateCredential = DelegateCredential::associated(&key, addr) .serialize() .try_into() .unwrap(); - assert_eq!(recovered.account_addr.as_deref(), Some(addr)); + assert_eq!(recovered.account_addr.as_deref(), Some(addr.as_slice())); } #[test] @@ -293,18 +288,16 @@ mod tests { )); } + /// An account address is opaque bytes — a raw key, not text — so bytes that + /// are not valid UTF-8 survive the round trip verbatim. #[test] - fn invalid_utf8_account_addr_rejected() { + fn non_utf8_account_addr_roundtrips() { let key = test_key(); - // Build a valid credential then corrupt the account_addr bytes - let mut bytes = DelegateCredential::unassociated(&key).serialize(); - // Append a TAG_ACCOUNT_ADDR field with invalid UTF-8 - bytes.push(DelegateCredential::TAG_ACCOUNT_ADDR); - bytes.push(3); // len - bytes.extend_from_slice(&[0xFF, 0xFE, 0xFD]); // invalid UTF-8 - assert!(matches!( - DelegateCredential::try_from(bytes), - Err(ClientError::BadlyFormedCredential) - )); + let addr = [0xFFu8, 0xFE, 0xFD]; + let recovered: DelegateCredential = DelegateCredential::associated(&key, &addr) + .serialize() + .try_into() + .unwrap(); + assert_eq!(recovered.account_addr.as_deref(), Some(addr.as_slice())); } } diff --git a/crates/generic-chat/src/event.rs b/crates/generic-chat/src/event.rs index 4618e026..fd6a4021 100644 --- a/crates/generic-chat/src/event.rs +++ b/crates/generic-chat/src/event.rs @@ -15,11 +15,11 @@ use libchat::{ConversationClass, IdentId}; /// `account` is present only when the sender associated an account *and* the /// account → device directory confirmed this device belongs to it — spoofed or /// unconfirmable claims never reach the application, so a `Some` account is -/// always verified. `local_identity` is the sending device (delegate key), -/// hex-encoded. +/// always verified. It carries the account address bytes. `local_identity` is +/// the sending device (delegate key), hex-encoded. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MessageSender { - pub account: Option, + pub account: Option>, pub local_identity: IdentId, } diff --git a/crates/generic-chat/src/lib.rs b/crates/generic-chat/src/lib.rs index 91d1318f..cfde1713 100644 --- a/crates/generic-chat/src/lib.rs +++ b/crates/generic-chat/src/lib.rs @@ -6,7 +6,9 @@ mod errors; mod event; pub use builder::{ChatClientBuilder, Unset}; -pub use client::{ChatClient, GroupMember, GroupMetadata, Transport}; +pub use client::{ + ChatClient, GroupMember, GroupMetadata, LogosAuthVerifier, MemberWithAuthResult, Transport, +}; pub use delegate::DelegateSigner; pub use delivery_in_process::{InProcessDelivery, MessageBus}; pub use errors::ClientError; diff --git a/crates/generic-chat/tests/group_v2.rs b/crates/generic-chat/tests/group_v2.rs index 9a42b7cf..9b5847df 100644 --- a/crates/generic-chat/tests/group_v2.rs +++ b/crates/generic-chat/tests/group_v2.rs @@ -12,7 +12,7 @@ use libchat::ChatStorage; use logos_account::TestLogosAccount; use logos_generic_chat::{ ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner, Event, GroupMetadata, - GroupV2Config, InProcessDelivery, MessageBus, + GroupV2Config, InProcessDelivery, LogosAuthVerifier, MessageBus, }; /// Metadata for a group these tests create without a name or description. @@ -34,15 +34,15 @@ fn fast_group_v2_config() -> GroupV2Config { } } -type TestClient = ChatClient; +type TestClient = ChatClient; -/// A client for a fresh account: mints the account and a delegate, publishes -/// the endorsing bundle, and builds the client on the shared bus/registry with -/// the fast GroupV2 timers. Returns the account address peers invite by. +/// A client for a fresh account: mints the account and a delegate, endorses the +/// delegate on the account, and builds the client on the shared bus/registry +/// with the fast GroupV2 timers. Returns the account address peers invite by. fn create_test_client( message_bus: MessageBus, reg: EphemeralRegistry, -) -> (TestClient, Receiver, String) { +) -> (TestClient, Receiver, Vec) { create_test_client_with(message_bus, reg, fast_group_v2_config()) } @@ -52,20 +52,21 @@ fn create_test_client_with( message_bus: MessageBus, mut reg: EphemeralRegistry, config: GroupV2Config, -) -> (TestClient, Receiver, String) { - let account = TestLogosAccount::new(); +) -> (TestClient, Receiver, Vec) { + let mut account = TestLogosAccount::new(); let delegate = DelegateSigner::random(); account - .add_delegate_signer(&mut reg, delegate.public_key()) + .endorse_ed25519_signer(&mut reg, delegate.public_key()) .unwrap(); - let (client, events) = ChatClientBuilder::new(account.address()) + let (client, events) = ChatClientBuilder::new(account.address().to_bytes()) + .auth(LogosAuthVerifier::new()) .ident(delegate) .transport(InProcessDelivery::new(message_bus)) .registration(reg) .group_v2_config(config) .build() .expect("client create"); - let addr = client.addr().to_string(); + let addr = client.addr().to_vec(); (client, events, addr) } @@ -107,17 +108,13 @@ fn wait_for_group_started(events: &Receiver, label: &str) -> String { /// roster settles asynchronously as each member applies the add commit, so it is /// polled rather than snapshotted; members still awaiting that commit are /// skipped so an invite alone never reads as convergence. -fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&str]) { +fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&[u8]]) { use std::collections::BTreeSet; - let want: BTreeSet<&str> = expected.iter().copied().collect(); + let want: BTreeSet> = expected.iter().map(|a| a.to_vec()).collect(); let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { let roster = client.group_members(convo_id).expect("group_members"); - let got: BTreeSet<&str> = roster - .iter() - .filter(|m| !m.pending) - .filter_map(|m| m.account.as_ref().map(|a| a.as_str())) - .collect(); + let got: BTreeSet> = roster.iter().filter_map(|m| m.account.clone()).collect(); if got == want { return; } @@ -129,14 +126,14 @@ fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&str]) } /// Wait for `content` to arrive and return the sender's verified account. -fn wait_for_message(events: &Receiver, content: &[u8]) -> Option { +fn wait_for_message(events: &Receiver, content: &[u8]) -> Option> { let label = format!("MessageReceived({})", String::from_utf8_lossy(content)); wait_for_event(events, &label, Duration::from_secs(10), |e| match e { Event::MessageReceived { content: got, sender, .. - } if got == content => Some(sender.account.as_ref().map(|a| a.as_str().to_string())), + } if got == content => Some(sender.account.clone()), _ => None, }) } @@ -154,7 +151,7 @@ fn group_v2_three_members() { let (mut pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone()); let convo_id = saro - .create_group_conversation(&[&raya_addr], unnamed_group()) + .create_group_conversation(&[raya_addr.as_slice()], unnamed_group()) .expect("saro create group"); // The invite lands once saro's steward commit finalizes (wakeup-driven); @@ -163,24 +160,32 @@ fn group_v2_three_members() { assert_eq!(raya_convo_id, convo_id); // Both sides see the two-account roster once the add commits. - wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]); - wait_for_members(&mut raya, &raya_convo_id, &[&saro_addr, &raya_addr]); + wait_for_members( + &mut saro, + &convo_id, + &[saro_addr.as_slice(), raya_addr.as_slice()], + ); + wait_for_members( + &mut raya, + &raya_convo_id, + &[saro_addr.as_slice(), raya_addr.as_slice()], + ); saro.send_message(&convo_id, b"hello raya").unwrap(); assert_eq!( wait_for_message(&raya_events, b"hello raya").as_deref(), - Some(saro_addr.as_str()) + Some(saro_addr.as_slice()) ); raya.send_message(&raya_convo_id, b"hi saro").unwrap(); assert_eq!( wait_for_message(&saro_events, b"hi saro").as_deref(), - Some(raya_addr.as_str()) + Some(raya_addr.as_slice()) ); // A non-creator grows the group: raya proposes pax, the steward commits, // and raya (who holds the pending invite) routes the welcome to pax. - raya.add_group_members(&raya_convo_id, &[&pax_addr]) + raya.add_group_members(&raya_convo_id, &[pax_addr.as_slice()]) .expect("raya add pax"); let pax_convo_id = wait_for_group_started(&pax_events, "pax ConversationStarted"); assert_eq!(pax_convo_id, convo_id); @@ -190,25 +195,29 @@ fn group_v2_three_members() { saro.send_message(&convo_id, b"all three?").unwrap(); assert_eq!( wait_for_message(&raya_events, b"all three?").as_deref(), - Some(saro_addr.as_str()) + Some(saro_addr.as_slice()) ); assert_eq!( wait_for_message(&pax_events, b"all three?").as_deref(), - Some(saro_addr.as_str()) + Some(saro_addr.as_slice()) ); pax.send_message(&pax_convo_id, b"pax is in").unwrap(); assert_eq!( wait_for_message(&saro_events, b"pax is in").as_deref(), - Some(pax_addr.as_str()) + Some(pax_addr.as_slice()) ); assert_eq!( wait_for_message(&raya_events, b"pax is in").as_deref(), - Some(pax_addr.as_str()) + Some(pax_addr.as_slice()) ); // All three rosters converge on the same three accounts. - let all = [saro_addr.as_str(), raya_addr.as_str(), pax_addr.as_str()]; + let all = [ + saro_addr.as_slice(), + raya_addr.as_slice(), + pax_addr.as_slice(), + ]; wait_for_members(&mut saro, &convo_id, &all); wait_for_members(&mut raya, &raya_convo_id, &all); wait_for_members(&mut pax, &pax_convo_id, &all); @@ -238,8 +247,11 @@ fn peers_invited_to_many_groups() { let mut convo_ids = Vec::new(); for _ in 0..GROUPS { convo_ids.push( - saro.create_group_conversation(&[&raya_addr, &pax_addr], unnamed_group()) - .expect("saro create group"), + saro.create_group_conversation( + &[raya_addr.as_slice(), pax_addr.as_slice()], + unnamed_group(), + ) + .expect("saro create group"), ); } @@ -256,11 +268,11 @@ fn peers_invited_to_many_groups() { saro.send_message(convo_id, &msg).unwrap(); assert_eq!( wait_for_message(&raya_events, &msg).as_deref(), - Some(saro_addr.as_str()) + Some(saro_addr.as_slice()) ); assert_eq!( wait_for_message(&pax_events, &msg).as_deref(), - Some(saro_addr.as_str()) + Some(saro_addr.as_slice()) ); } @@ -280,11 +292,8 @@ fn group_creator_is_in_own_roster() { .create_group_conversation(&[], unnamed_group()) .expect("empty group"); let roster = saro.group_members(&convo_id).expect("group_members"); - let accounts: Vec> = roster - .iter() - .map(|m| m.account.as_ref().map(|a| a.as_str())) - .collect(); - assert_eq!(accounts, vec![Some(saro_addr.as_str())]); + let accounts: Vec>> = roster.iter().map(|m| m.account.clone()).collect(); + assert_eq!(accounts, vec![Some(saro_addr.clone())]); } /// An invited member joins the roster immediately, flagged pending: the add is @@ -307,19 +316,19 @@ fn invited_member_is_pending_until_the_group_commits() { let convo_id = saro .create_group_conversation(&[], unnamed_group()) .expect("empty group"); - saro.add_group_members(&convo_id, &[&raya_addr]) + saro.add_group_members(&convo_id, &[raya_addr.as_slice()]) .expect("saro invites raya"); let roster = saro.group_members(&convo_id).expect("group_members"); - let accounts = |pending: bool| -> Vec<&str> { + let accounts = |pending: bool| -> Vec<&[u8]> { roster .iter() .filter(|m| m.pending == pending) - .filter_map(|m| m.account.as_ref().map(|a| a.as_str())) + .filter_map(|m| m.account.as_deref()) .collect() }; - assert_eq!(accounts(false), vec![saro_addr.as_str()]); - assert_eq!(accounts(true), vec![raya_addr.as_str()]); + assert_eq!(accounts(false), vec![saro_addr.as_slice()]); + assert_eq!(accounts(true), vec![raya_addr.as_slice()]); } /// The pending flag is transient: once the group commits the add, the invitee @@ -337,11 +346,15 @@ fn pending_clears_once_the_add_commits() { let convo_id = saro .create_group_conversation(&[], unnamed_group()) .expect("empty group"); - saro.add_group_members(&convo_id, &[&raya_addr]) + saro.add_group_members(&convo_id, &[raya_addr.as_slice()]) .expect("saro invites raya"); let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted"); - wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]); + wait_for_members( + &mut saro, + &convo_id, + &[saro_addr.as_slice(), raya_addr.as_slice()], + ); let roster = saro.group_members(&convo_id).expect("group_members"); assert!( @@ -371,21 +384,24 @@ fn add_batch_with_missing_key_package_invites_no_one() { let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone()); let (_pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone()); - // Ghost: its account endorses a device in the directory, but that device - // never registered a key package (no client was built for it). - let ghost_account = TestLogosAccount::new(); + // Ghost: its account endorses a device, but that device never registered a + // key package (no client was built for it). + let mut ghost_account = TestLogosAccount::new(); let ghost_delegate = DelegateSigner::random(); ghost_account - .add_delegate_signer(&mut reg, ghost_delegate.public_key()) + .endorse_ed25519_signer(&mut reg, ghost_delegate.public_key()) .unwrap(); let convo_id = saro - .create_group_conversation(&[&raya_addr], unnamed_group()) + .create_group_conversation(&[raya_addr.as_slice()], unnamed_group()) .expect("saro create group"); wait_for_group_started(&raya_events, "raya ConversationStarted"); - saro.add_group_members(&convo_id, &[&ghost_account.address(), &pax_addr]) - .expect_err("ghost has no key package"); + saro.add_group_members( + &convo_id, + &[ghost_account.address().to_bytes(), pax_addr.as_slice()], + ) + .expect_err("ghost has no key package"); // Pax was in the failed batch and must not have been invited. assert!( @@ -410,8 +426,8 @@ fn group_invite_of_unpublished_account_is_an_error() { let unpublished = TestLogosAccount::new(); let err = saro - .create_group_conversation(&[&unpublished.address()], unnamed_group()) - .expect_err("no bundle published for the account"); + .create_group_conversation(&[unpublished.address().to_bytes()], unnamed_group()) + .expect_err("nothing published for the account"); assert!(matches!( err, logos_generic_chat::ClientError::AccountResolution(_) @@ -421,8 +437,8 @@ fn group_invite_of_unpublished_account_is_an_error() { .create_group_conversation(&[], unnamed_group()) .expect("empty group"); let err = saro - .add_group_members(&convo_id, &[&unpublished.address()]) - .expect_err("no bundle published for the account"); + .add_group_members(&convo_id, &[unpublished.address().to_bytes()]) + .expect_err("nothing published for the account"); assert!(matches!( err, logos_generic_chat::ClientError::AccountResolution(_) @@ -442,7 +458,7 @@ fn group_metadata_reaches_joiners() { let convo_id = saro .create_group_conversation( - &[&raya_addr], + &[raya_addr.as_slice()], GroupMetadata::new("Book Club", "Weekly reads"), ) .expect("saro create group"); diff --git a/crates/generic-chat/tests/saro_and_raya.rs b/crates/generic-chat/tests/saro_and_raya.rs index 4c7f0d53..7e89482a 100644 --- a/crates/generic-chat/tests/saro_and_raya.rs +++ b/crates/generic-chat/tests/saro_and_raya.rs @@ -2,41 +2,33 @@ use std::time::Duration; use components::EphemeralRegistry; use crossbeam_channel::{Receiver, Sender}; -use crypto::Ed25519VerifyingKey; use logos_account::TestLogosAccount; use logos_generic_chat::{ AddressedEnvelope, ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner, - DeliveryService, Event, InProcessDelivery, MessageBus, Transport, + DeliveryService, Event, InProcessDelivery, LogosAuthVerifier, MessageBus, 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, -) { - account.add_delegate_signer(reg, device).unwrap(); -} - -/// A client for a fresh account: mints the account and a delegate, publishes -/// the endorsing bundle, and builds the client on the shared bus/registry. +/// A client for a fresh account: mints the account and a delegate, endorses the +/// delegate on the account, and builds the client on the shared bus/registry. #[allow(clippy::type_complexity)] fn create_test_client( message_bus: MessageBus, mut reg: EphemeralRegistry, ) -> Result< ( - ChatClient, + ChatClient, Receiver, ), logos_generic_chat::ClientError, > { - let account = TestLogosAccount::new(); + let mut account = TestLogosAccount::new(); let delegate = DelegateSigner::random(); - publish_device_bundle(&mut reg, &account, delegate.public_key()); + account + .endorse_ed25519_signer(&mut reg, delegate.public_key()) + .unwrap(); let d = InProcessDelivery::new(message_bus); - ChatClientBuilder::new(account.address()) + ChatClientBuilder::new(account.address().to_bytes()) + .auth(LogosAuthVerifier::new()) .ident(delegate) .transport(d) .registration(reg) @@ -89,18 +81,21 @@ fn direct_v1_standalone_integration() { let mut reg_service = EphemeralRegistry::new(); - // Create accounts and delegates, and publish device bundles so the - // receiver can verify the account → device mapping carried in the + // Create accounts and delegates, and endorse each delegate on its account so + // the receiver can verify the account → device mapping carried in the // sender's credential. - let saro_account = TestLogosAccount::new(); - let saro_account_id = saro_account.address(); + let mut saro_account = TestLogosAccount::new(); + let saro_account_id = saro_account.address().to_bytes().to_vec(); let saro_delegate = DelegateSigner::random(); let saro_device_id = hex::encode(saro_delegate.public_key().as_ref()); - publish_device_bundle(&mut reg_service, &saro_account, saro_delegate.public_key()); + saro_account + .endorse_ed25519_signer(&mut reg_service, saro_delegate.public_key()) + .unwrap(); // Build saro's client with its account so its outbound messages carry a - // credential the receiver can verify against the published bundle. + // credential the receiver can verify against the endorsement. let (mut saro, _saro_events) = ChatClientBuilder::new(saro_account_id.clone()) + .auth(LogosAuthVerifier::new()) .ident(saro_delegate) .transport(InProcessDelivery::new(bus.clone())) .registration(reg_service.clone()) @@ -125,12 +120,9 @@ fn direct_v1_standalone_integration() { content, sender, .. } => { assert_eq!(content.as_slice(), b"Hey from saro"); - // saro associated an account and published a matching bundle, so the + // saro associated an account that endorses its delegate, so the // sender surfaces with a verified account and its device. - assert_eq!( - sender.account.as_ref().map(|a| a.as_str()), - Some(saro_account_id.as_str()) - ); + assert_eq!(sender.account.as_deref(), Some(saro_account_id.as_slice())); assert_eq!(sender.local_identity.as_str(), saro_device_id.as_str()); Ok(()) } @@ -148,12 +140,15 @@ fn direct_v1_by_account_address() { let bus = MessageBus::default(); let mut reg_service = EphemeralRegistry::new(); - let raya_account = TestLogosAccount::new(); - let raya_account_addr = raya_account.address(); + let mut raya_account = TestLogosAccount::new(); + let raya_account_addr = raya_account.address().to_bytes().to_vec(); let raya_delegate = DelegateSigner::random(); - publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key()); + raya_account + .endorse_ed25519_signer(&mut reg_service, raya_delegate.public_key()) + .unwrap(); let (mut raya, raya_events) = ChatClientBuilder::new(raya_account_addr.clone()) + .auth(LogosAuthVerifier::new()) .ident(raya_delegate) .transport(InProcessDelivery::new(bus.clone())) .registration(reg_service.clone()) @@ -163,7 +158,7 @@ fn direct_v1_by_account_address() { create_test_client(bus.clone(), reg_service.clone()).expect("client create"); // Raya's shared address is her account address, not her signer id. - assert_eq!(raya.addr(), raya_account_addr.as_str()); + assert_eq!(raya.addr(), raya_account_addr); let convo_id = saro.create_direct_conversation(&raya_account_addr).unwrap(); // DirectV1 is the pairwise shape, so the joiner sees it classed Private even @@ -191,11 +186,11 @@ fn direct_v1_by_account_address() { content, sender, .. } => { assert_eq!(content.as_slice(), b"hi saro"); - // raya's bundle endorses her delegate, so her sender surfaces with + // raya's account endorses her delegate, so her sender surfaces with // the verified account. assert_eq!( - sender.account.as_ref().map(|a| a.as_str()), - Some(raya_account_addr.as_str()) + sender.account.as_deref(), + Some(raya_account_addr.as_slice()) ); Ok(()) } @@ -234,8 +229,8 @@ fn saro_raya_message_exchange() { } => { assert_eq!(convo_id, raya_convo_id); assert_eq!(content.as_slice(), b"hello raya"); - // saro's account published a bundle endorsing its delegate, so the - // sender surfaces a verified account. + // saro's account endorses its delegate, so the sender surfaces a + // verified account. assert!(sender.account.is_some()); assert!(!sender.local_identity.as_str().is_empty()); Ok(()) @@ -316,19 +311,16 @@ fn direct_conversation_lists_its_participants() { create_test_client(bus.clone(), reg.clone()).expect("client create"); let (raya, _raya_events) = create_test_client(bus.clone(), reg.clone()).expect("client create"); - let saro_addr = saro.addr().to_string(); - let raya_addr = raya.addr().to_string(); + let saro_addr = saro.addr().to_vec(); + let raya_addr = raya.addr().to_vec(); let convo_id = saro .create_direct_conversation(&raya_addr) .expect("convo create"); let roster = saro.group_members(&convo_id).expect("group_members"); - let mut accounts: Vec> = roster - .iter() - .map(|m| m.account.as_ref().map(|a| a.as_str())) - .collect(); + let mut accounts: Vec> = roster.iter().map(|m| m.account.as_deref()).collect(); accounts.sort(); - let mut expected = vec![Some(saro_addr.as_str()), Some(raya_addr.as_str())]; + let mut expected = vec![Some(saro_addr.as_slice()), Some(raya_addr.as_slice())]; expected.sort(); assert_eq!(accounts, expected); @@ -405,7 +397,8 @@ fn malformed_inbound_surfaces_as_error_event() { let delivery = FailingDelivery::new(); let inbound_tx = delivery.inbound_sender(); - let (_client, events) = ChatClientBuilder::new(TestLogosAccount::new().address()) + let (_client, events) = ChatClientBuilder::new(TestLogosAccount::new().address().to_bytes()) + .auth(LogosAuthVerifier::new()) .transport(delivery) .build() .expect("client create"); @@ -422,7 +415,7 @@ fn malformed_inbound_surfaces_as_error_event() { } /// Opening a conversation by an address whose account never published a -/// device bundle fails at resolution, not with a late key-package miss. +/// device list fails at resolution, not with a late key-package miss. #[test] fn unpublished_account_address_is_an_error() { let bus = MessageBus::default(); @@ -433,15 +426,15 @@ fn unpublished_account_address_is_an_error() { let unpublished = TestLogosAccount::new(); let err = saro - .create_direct_conversation(&unpublished.address()) - .expect_err("no bundle published for the account"); + .create_direct_conversation(unpublished.address().to_bytes()) + .expect_err("nothing published for the account"); assert!(matches!( err, logos_generic_chat::ClientError::AccountResolution(_) )); let err = saro - .create_direct_conversation("not-an-account-address") + .create_direct_conversation(b"not-an-account-address") .expect_err("not an account key"); assert!(matches!( err, diff --git a/crates/logos-chat/src/logos.rs b/crates/logos-chat/src/logos.rs index beebbdd1..e2e7ca16 100644 --- a/crates/logos-chat/src/logos.rs +++ b/crates/logos-chat/src/logos.rs @@ -22,7 +22,8 @@ use libchat::{ChatStorage, StorageConfig}; use logos_account::TestLogosAccount; use logos_generic_chat::{ - ChatClient, ChatClientBuilder, ClientError, DelegateSigner, Event, GroupV2Config, Transport, + ChatClient, ChatClientBuilder, ClientError, DelegateSigner, Event, GroupV2Config, + LogosAuthVerifier, Transport, }; /// The endpoint for the account and keypackage registration service. @@ -127,16 +128,16 @@ pub fn open_with_transport( transport: T, ) -> Result< ( - ChatClient, ChatStorage>, + ChatClient, ChatStorage>, Receiver, ), ClientError, > { // A fresh account endorsing a fresh delegate each open: the account - // key is dropped after publishing the bundle, so devices cannot be - // added later. A caller-supplied, custody-holding account replaces + // key is dropped after the endorsement, so devices cannot be added + // later. A caller-supplied, custody-holding account replaces // this once the platform provides one. - let account = TestLogosAccount::new(); + let mut account = TestLogosAccount::new(); let delegate = DelegateSigner::random(); let mut registry = ContactRegistry::new( transport.clone(), @@ -144,9 +145,10 @@ pub fn open_with_transport( config.registry_publish_mode, ); account - .add_delegate_signer(&mut registry, delegate.public_key()) + .endorse_ed25519_signer(&mut registry, delegate.public_key()) .map_err(|e| ClientError::BundlePublish(e.to_string()))?; - let mut builder = ChatClientBuilder::new(account.address()) + let mut builder = ChatClientBuilder::new(account.address().to_bytes()) + .auth(LogosAuthVerifier::new()) .ident(delegate) .transport(transport) .registration(registry) @@ -168,5 +170,9 @@ pub fn open_with_transport( /// and encrypted [`ChatStorage`] — running an embedded logos-delivery node as /// its transport. Open one with [`open`], or swap the transport via /// [`open_with_transport`]. -pub type LogosChatClient = - ChatClient, ChatStorage>; +pub type LogosChatClient = ChatClient< + LogosAuthVerifier, + EmbeddedLogosDelivery, + ContactRegistry, + ChatStorage, +>;