diff --git a/Cargo.lock b/Cargo.lock index 4bff5b1..9fcbbe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "clap", "crossbeam-channel", "crossterm 0.29.0", + "logos-account", "logos-chat", "ratatui", "serde", @@ -1466,6 +1467,7 @@ dependencies = [ "crypto", "hex", "libchat", + "logos-account", "reqwest 0.12.28", "serde", "serde_json", @@ -3042,8 +3044,8 @@ version = "0.1.0" dependencies = [ "chat-sqlite", "components", + "crypto", "libchat", - "logos-account", "shared-traits", "storage", "tempfile", @@ -3589,8 +3591,9 @@ name = "logos-account" version = "0.1.0" dependencies = [ "crypto", - "libchat", + "hex", "shared-traits", + "thiserror", ] [[package]] diff --git a/bin/chat-cli/Cargo.toml b/bin/chat-cli/Cargo.toml index d921dec..bafef72 100644 --- a/bin/chat-cli/Cargo.toml +++ b/bin/chat-cli/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] # Workspace dependencies (sorted) crossbeam-channel = { workspace = true } +logos-account = { workspace = true, features = ["dev"] } logos-chat = { workspace = true, features = ["embedded-p2p-delivery"] } # External dependencies (sorted) diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 534515a..e550ae5 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use arboard::Clipboard; use crossbeam_channel::Receiver; -use logos_chat::{ChatClient, ChatStore, Event, IdentityProvider, RegistrationService, Transport}; +use logos_chat::{AccountDirectory, ChatClient, ChatStore, Event, RegistrationService, Transport}; use serde::{Deserialize, Serialize}; use crate::utils::now; @@ -41,14 +41,13 @@ pub struct AppState { pub active_chat: Option, } -pub struct ChatApp +pub struct ChatApp where - I: IdentityProvider + Send + 'static, T: Transport, - R: RegistrationService + Send + 'static, + 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. @@ -59,15 +58,14 @@ where state_path: PathBuf, } -impl ChatApp +impl ChatApp where - I: IdentityProvider + Send, T: Transport, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send, { pub fn new( - client: ChatClient, + client: ChatClient, events: Receiver, user_name: &str, data_dir: &Path, diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index 04482f6..cfb5f34 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -8,9 +8,10 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; use crossbeam_channel::Receiver; +use logos_account::TestLogosAccount; use logos_chat::{ - ChatClient, ChatClientBuilder, ChatStore, DelegateSigner, Event, HttpRegistry, - IdentityProvider, LogosChatClient, NETWORK_PRESET, REGISTRY_ENDPOINT, RegistrationService, + AccountDirectory, ChatClient, ChatClientBuilder, ChatStore, DelegateSigner, Event, + HttpRegistry, LogosChatClient, NETWORK_PRESET, REGISTRY_ENDPOINT, RegistrationService, StorageConfig, Transport, }; @@ -106,10 +107,19 @@ fn main() -> Result<()> { .context("failed to create file transport")?; let endpoint = cli.registry_url.as_deref().unwrap_or(REGISTRY_ENDPOINT); - let (client, events) = ChatClientBuilder::new() - .ident(DelegateSigner::random()) + // A fresh dev account endorsing a fresh delegate each launch, + // mirroring `LogosChatClient::open`. + let account = TestLogosAccount::new(); + let delegate = DelegateSigner::random(); + let mut registry = HttpRegistry::new(endpoint); + account + .add_delegate_signer(&mut registry, delegate.public_key()) + .map_err(|e| anyhow::anyhow!("{e:?}")) + .context("failed to publish the device bundle")?; + let (client, events) = ChatClientBuilder::new(account.address()) + .ident(delegate) .transport(transport) - .registration(HttpRegistry::new(endpoint)) + .registration(registry) .storage_config(StorageConfig::Encrypted { path: db_str, key: "chat-cli".to_string(), @@ -135,15 +145,14 @@ fn db_path(cli: &Cli) -> Result { .to_string()) } -fn launch_tui( - client: ChatClient, +fn launch_tui( + client: ChatClient, events: Receiver, cli: &Cli, ) -> Result<()> where - I: IdentityProvider + Send, T: Transport, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send, { let mut app = ChatApp::new(client, events, &cli.name, &cli.data)?; @@ -158,11 +167,10 @@ where result } -fn run_app(terminal: &mut ui::Tui, app: &mut ChatApp) -> Result<()> +fn run_app(terminal: &mut ui::Tui, app: &mut ChatApp) -> Result<()> where - I: IdentityProvider + Send, T: Transport, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send, { loop { diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index 7dd2241..595c48e 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -16,7 +16,7 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, }; -use logos_chat::{ChatStore, IdentityProvider, RegistrationService, Transport}; +use logos_chat::{AccountDirectory, ChatStore, RegistrationService, Transport}; use crate::app::ChatApp; @@ -38,11 +38,10 @@ pub fn restore() -> io::Result<()> { } /// Draw the UI. -pub fn draw(frame: &mut Frame, app: &ChatApp) +pub fn draw(frame: &mut Frame, app: &ChatApp) where - I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { let chunks = Layout::default() @@ -61,11 +60,10 @@ where draw_status(frame, app, chunks[3]); } -fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) where - I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { let title = match app.current_session() { @@ -90,11 +88,10 @@ where frame.render_widget(header, area); } -fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) where - I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { let remote_name = app @@ -182,11 +179,10 @@ where frame.render_stateful_widget(messages_widget, area, &mut list_state); } -fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) where - I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { // Inner width: area minus borders (2). @@ -215,11 +211,10 @@ where frame.set_cursor_position((cursor_x, area.y + 1)); } -fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) +fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) where - I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { let status = Paragraph::new(app.status.as_str()) @@ -231,11 +226,10 @@ where } /// Handle keyboard events. -pub fn handle_events(app: &mut ChatApp) -> io::Result +pub fn handle_events(app: &mut ChatApp) -> io::Result where - I: IdentityProvider + Send + 'static, D: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { // Poll for events with a short timeout to allow checking incoming messages diff --git a/core/account/Cargo.toml b/core/account/Cargo.toml index dbf71a9..17db547 100644 --- a/core/account/Cargo.toml +++ b/core/account/Cargo.toml @@ -9,7 +9,8 @@ dev = [] [dependencies] # Workspace dependencies (sorted) crypto = { workspace = true } -libchat = { workspace = true } shared-traits = { workspace = true } # External dependencies (sorted) +hex = "0.4.3" +thiserror = "2" diff --git a/core/account/src/account.rs b/core/account/src/account.rs index db2959b..6f40d1c 100644 --- a/core/account/src/account.rs +++ b/core/account/src/account.rs @@ -1,43 +1,183 @@ use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; -use shared_traits::{IdentId, IdentIdRef}; -use libchat::IdentityProvider; +use crate::directory::{AccountDirectory, SignedDeviceBundle, encode_bundle_payload}; -/// A Test Focused LogosAccount using a pre-defined identifier. -/// The test account is not persisted, and uses a single user provided id. +/// 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, +} + +/// A Test Focused LogosAccount. +/// The test account is not persisted. /// This account type should not be used in a production system. pub struct TestLogosAccount { - id: IdentId, signing_key: Ed25519SigningKey, verifying_key: Ed25519VerifyingKey, } +impl Default for TestLogosAccount { + fn default() -> Self { + Self::new() + } +} + impl TestLogosAccount { - pub fn new(explicit_id: impl Into) -> Self { + pub fn new() -> Self { let signing_key = Ed25519SigningKey::generate(); let verifying_key = signing_key.verifying_key(); Self { - id: IdentId::new(explicit_id.into()), signing_key, verifying_key, } } -} -impl IdentityProvider for TestLogosAccount { - fn id(&self) -> IdentIdRef<'_> { - &self.id - } - - fn display_name(&self) -> String { - self.id.to_string() - } - - fn public_key(&self) -> &Ed25519VerifyingKey { + /// The account verifying key; its hex is the account address peers share. + pub fn public_key(&self) -> &Ed25519VerifyingKey { &self.verifying_key } - fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature { - self.signing_key.sign(payload) + /// The account address peers share: the hex of the verifying key. + pub fn address(&self) -> String { + hex::encode(self.verifying_key.as_ref()) + } + + /// Add `signer` (the delegate signer's verifying key) to this account's directory bundle. + /// + /// 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: &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); + let bundle = SignedDeviceBundle { + account_pub: self.verifying_key.clone(), + payload, + signature, + }; + + directory + .publish(&bundle) + .map_err(|e| AddDelegateSignerError::Directory(e.to_string())) + } +} + +#[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_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. + #[test] + fn first_add_delegate_signer_lists_the_signer() { + let mut dir = FakeDir::default(); + let account = TestLogosAccount::new(); + let device = Ed25519SigningKey::generate().verifying_key(); + + account.add_delegate_signer(&mut dir, &device).unwrap(); + + let (lamport, devices) = device_set(&dir, &account); + assert_eq!(lamport, 0); + assert_eq!(devices, vec![hex::encode(device.as_ref())]); + } + + /// A second device is merged into the existing set with a bumped lamport, + /// preserving the first device. + #[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())] + ); + } + + /// Re-adding an already-listed device keeps the set and still bumps the + /// lamport (a refresh, not a duplicate). + #[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(); + + account.add_delegate_signer(&mut dir, &device).unwrap(); + account.add_delegate_signer(&mut dir, &device).unwrap(); + + let (lamport, devices) = device_set(&dir, &account); + assert_eq!(lamport, 1); + assert_eq!(devices, vec![hex::encode(device.as_ref())]); } } diff --git a/core/conversations/src/account_directory.rs b/core/account/src/directory.rs similarity index 85% rename from core/conversations/src/account_directory.rs rename to core/account/src/directory.rs index 2a994cb..0814889 100644 --- a/core/conversations/src/account_directory.rs +++ b/core/account/src/directory.rs @@ -5,13 +5,10 @@ //! one such bundle per account so that an inviter can resolve an account public //! key to every device it must invite. //! -//! Two roles are kept distinct from the per-device [`IdentityProvider`]: -//! -//! - [`AccountAuthority`] — the injected account key. Custody (wallet, enclave, -//! another device) stays outside libchat; we only ever ask it to sign. Present -//! only where the user authorizes a device change. -//! - [`AccountDirectory`] — the client that publishes and fetches+verifies the -//! bundle against the directory service. +//! [`AccountDirectory`] is the client that publishes and fetches+verifies the +//! bundle against the directory service. Signing a bundle is account +//! functionality (e.g. [`TestLogosAccount::add_delegate_signer`](crate::TestLogosAccount)); +//! the account key never leaves the account type. //! //! The bundle `payload` is opaque to the server. Both the signing side //! ([`encode_bundle_payload`]) and the verifying side ([`verify_bundle`]) live @@ -24,8 +21,8 @@ use shared_traits::IdentIdRef; use thiserror::Error; /// A device (LocalIdentity) verifying key, hex-encoded — the same shape as the -/// keypackage registry's `device_id`, so values flow straight into -/// [`KeyPackageProvider::retrieve`](crate::service_traits::KeyPackageProvider). +/// keypackage registry's `device_id`, so values flow straight into a keypackage +/// retrieval. pub type DeviceId = String; /// The account's monotonic version counter, bumped on every membership change. @@ -48,8 +45,8 @@ pub const BUNDLE_VERSION: u8 = 1; pub const BUNDLE_DOMAIN: &[u8] = b"libchat:account-device-bundle\0"; /// The signed device-list bundle. The `payload` bytes are exactly -/// what [`AccountAuthority::sign`] signed, so verifiers check the -/// signature over the same bytes they received. +/// what the account signed, so verifiers check the signature over +/// the same bytes they received. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignedDeviceBundle { /// The account verifying key this bundle belongs to. Used for addressing on @@ -71,30 +68,10 @@ pub struct DeviceSet { pub devices: Vec, } -/// The account capability, injected by the platform. -/// -/// Custody of the account key stays outside libchat — the library only ever asks -/// it to sign a device-list bundle. The same trait covers a local on-device key -/// (testnet) and an external signer (wallet/enclave), which is why [`sign`] is -/// fallible: an external signer can be offline or decline the prompt. -/// -/// Verification needs no authority — anyone holding the account verifying key -/// verifies with [`verify_bundle`]. -/// -/// [`sign`]: AccountAuthority::sign -pub trait AccountAuthority { - type Error: Display + Debug; - - /// The account verifying key identifying this participant. - fn account_pub(&self) -> &Ed25519VerifyingKey; - /// Sign the canonical bundle bytes with the account key. - fn sign(&self, payload: &[u8]) -> Result; -} - /// Client for the account → device directory service. /// -/// Mirrors [`RegistrationService`](crate::service_traits::RegistrationService): -/// an injected trait in core with an HTTP implementation in the extension layer. +/// Mirrors the core's `RegistrationService`: an injected trait with an HTTP +/// implementation in the extension layer. /// The service is untrusted, so [`fetch`](AccountDirectory::fetch) verifies the /// account signature before returning a [`DeviceSet`]. pub trait AccountDirectory: Debug { @@ -209,22 +186,33 @@ pub fn verify_bundle( }) } +/// Failures resolving an account address to its device ids. +#[derive(Debug, Error)] +pub enum ResolveError { + #[error("address is not an account key")] + NotAnAccountKey, + #[error("account has published no device bundle")] + NoDeviceBundle, + #[error("directory: {0}")] + Directory(String), +} + /// Resolve an account to the device ids whose KeyPackages must be fetched. /// -/// The directory is keyed by the account verifying key. When `account` is the hex -/// of such a key and a bundle exists, returns its verified device set. Otherwise -/// falls back to treating the identifier itself as a single device id — the -/// pre-directory behaviour — so opaque or never-published ids keep working. +/// The directory is keyed by the account verifying key: `account` must be the +/// hex of such a key, and a reachable account has published a bundle endorsing +/// at least one device. Anything else is an error — the distinct variants tell +/// a malformed address, an unpublished account, and a directory outage apart. pub fn resolve_device_ids( directory: &D, account: IdentIdRef, -) -> Result, D::Error> { - if let Some(account_key) = account_key_from_id(account) - && let Some(set) = directory.fetch(&account_key)? - { - return Ok(set.devices); - } - Ok(vec![account.to_string()]) +) -> Result, ResolveError> { + let account_key = account_key_from_id(account).ok_or(ResolveError::NotAnAccountKey)?; + let set = directory + .fetch(&account_key) + .map_err(|e| ResolveError::Directory(e.to_string()))? + .ok_or(ResolveError::NoDeviceBundle)?; + Ok(set.devices) } /// Interpret an identity id as the hex of an account verifying key, if it is one. @@ -363,12 +351,25 @@ mod tests { } } - /// No published bundle → fall back to the identifier as a single device id. + /// An address that is not the hex of an account key cannot be resolved. #[test] - fn resolve_falls_back_to_account_id() { + fn resolve_rejects_non_key_address() { let account = IdentId::new("pax"); - let resolved = resolve_device_ids(&FakeDir(None), &account).unwrap(); - assert_eq!(resolved, vec![account.to_string()]); + assert!(matches!( + resolve_device_ids(&FakeDir(None), &account), + Err(ResolveError::NotAnAccountKey) + )); + } + + /// An account that never published a bundle is unreachable. + #[test] + fn resolve_rejects_unpublished_account() { + let account_pub = Ed25519SigningKey::generate().verifying_key(); + let account_id = IdentId::new(hex::encode(account_pub.as_ref())); + assert!(matches!( + resolve_device_ids(&FakeDir(None), &account_id), + Err(ResolveError::NoDeviceBundle) + )); } /// A published bundle → resolve to its verified device ids (hex pubkeys). diff --git a/core/account/src/lib.rs b/core/account/src/lib.rs index c33c296..1b90c8c 100644 --- a/core/account/src/lib.rs +++ b/core/account/src/lib.rs @@ -1,5 +1,13 @@ +mod directory; + +pub use directory::{ + AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, DeviceSet, Lamport, + ResolveError, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload, + resolve_device_ids, verify_bundle, +}; + #[cfg(feature = "dev")] mod account; #[cfg(feature = "dev")] -pub use account::TestLogosAccount; +pub use account::{AddDelegateSignerError, TestLogosAccount}; diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index b562a4a..3dcf96e 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -12,7 +12,6 @@ use shared_traits::IdentIdRef; use std::collections::VecDeque; use tracing::debug; -use crate::account_directory::{AccountDirectory, resolve_device_ids}; use crate::conversation::ConversationIdRef; use crate::inbox_v2::MlsProvider; use crate::service_context::{ExternalServices, ServiceContext}; @@ -137,38 +136,27 @@ impl GroupV1Convo { Self::delivery_address_from_id(&self.convo_id) } - /// Resolve an account to a KeyPackage for *every* device it authorizes. - /// - /// First resolves the account to its device ids through the account - /// directory ([`resolve_device_ids`]), then fetches each device's - /// KeyPackage. When the account never published a bundle, resolution falls - /// back to a single device id equal to the account id — the pre-directory - /// behaviour — so single-device accounts are unaffected. - fn key_packages_for_account( + /// Fetch a signer's KeyPackage from the registry. Members are signer + /// (installation) ids; resolving an account to its signers is the caller's + /// concern, above the core. + fn key_package_for_signer( &self, - ident: IdentIdRef, + signer: IdentIdRef, provider: &impl MlsProvider, - registry: &(impl KeyPackageProvider + AccountDirectory), - ) -> Result, ChatError> { - let device_ids = - resolve_device_ids(registry, ident).map_err(|e| ChatError::Generic(e.to_string()))?; + registry: &impl KeyPackageProvider, + ) -> Result { + let retrieved = registry + .retrieve(signer.as_str()) + .map_err(|e| ChatError::Generic(e.to_string()))?; + let Some(keypkg_bytes) = retrieved else { + return Err(ChatError::Protocol(format!( + "no keypackage for signer {signer}" + ))); + }; - let mut keypackages = Vec::with_capacity(device_ids.len()); - for device_id in &device_ids { - let retrieved = registry - .retrieve(device_id) - .map_err(|e| ChatError::Generic(e.to_string()))?; - let Some(keypkg_bytes) = retrieved else { - return Err(ChatError::Protocol(format!( - "no keypackage for device {device_id} of account {ident}" - ))); - }; - - let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?; - let keypkg = key_package_in.validate(provider.crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version - keypackages.push(keypkg); - } - Ok(keypackages) + let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?; + let keypkg = key_package_in.validate(provider.crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version + Ok(keypkg) } fn send_message( @@ -324,12 +312,12 @@ impl GroupConvo for GroupV1Convo { )); } - // Resolve each account to a KeyPackage per authorized device and flatten - // them into one list — every device of every invitee becomes an MLS - // leaf, so all of a user's installations join the group. + // Members are signer (installation) ids: one KeyPackage each, one MLS + // leaf each. A caller inviting an account passes every signer id the + // account's directory bundle lists. let mut keypkgs = Vec::with_capacity(members.len()); for ident in members { - keypkgs.extend(self.key_packages_for_account(ident, &cx.mls_provider, &cx.registry)?); + keypkgs.push(self.key_package_for_signer(ident, &cx.mls_provider, &cx.registry)?); } let (commit, welcome, _group_info) = self @@ -346,9 +334,9 @@ impl GroupConvo for GroupV1Convo { .unwrap(); // TODO: (P3) Evaluate privacy/performance implications of an aggregated Welcome for multiple users - for account_id in members { + for signer_id in members { cx.mls_provider - .invite_user(&mut cx.ds, account_id, &welcome)?; + .invite_user(&mut cx.ds, signer_id, &welcome)?; } self.send_payload(cx, commit.to_bytes()?) diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index b30c7de..03cd8b5 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -17,6 +17,8 @@ use de_mls::{ }; use hashgraph_like_consensus::signing::EthereumConsensusSigner; use openmls::group::MlsGroupCreateConfig; +use openmls::prelude::tls_codec::Deserialize as _; +use openmls::prelude::{KeyPackageIn, OpenMlsProvider as _, ProtocolVersion}; use prost::Message; use shared_traits::{IdentId, IdentIdRef}; use std::sync::Arc; @@ -74,8 +76,10 @@ fn demls_config() -> ConversationConfig { pub struct GroupV2Convo { convo_id: String, conversation: Conversation, - /// Member-ids we proposed via add_member. We forward a welcome only to joiners WE invited. - pending_invites: Vec>, + /// Joiners WE invited, as `(member_id, signer_id)`: the de-mls member id + /// (the joiner's leaf credential content, read from its key package) paired + /// with the signer id its welcome is delivered to. + pending_invites: Vec<(Vec, String)>, } impl std::fmt::Debug for GroupV2Convo { @@ -274,20 +278,32 @@ where members: &[IdentIdRef], ) -> Result<(), ChatError> { // Record who WE invited before touching the conversation: after_op - // forwards a welcome only to joiners in pending_invites (the de-mls - // member-id is the invitee's id bytes). + // forwards a welcome only to joiners in pending_invites. Members are + // signer ids; the de-mls member id must match the id of the + // IdentityProvider that generated the key package (its MLS leaf + // credential content — de-mls matches members by credential), so it is + // read from the fetched key package rather than assumed equal to the + // signer id. for member in members { let kp_bytes = service_ctx .registry .retrieve(member.as_str()) .map_err(ChatError::generic)? .ok_or_else(|| ChatError::generic("No key package"))?; + let key_package_in = KeyPackageIn::tls_deserialize(&mut kp_bytes.as_slice())?; + let keypkg = key_package_in + .validate(service_ctx.mls_provider.crypto(), ProtocolVersion::Mls10)?; + let member_id = keypkg + .leaf_node() + .credential() + .serialized_content() + .to_vec(); self.pending_invites - .push(member.as_str().as_bytes().to_vec()); + .push((member_id.clone(), member.to_string())); self.conversation.add_member( &service_ctx.mls_provider, &service_ctx.mls_identity, - member.as_str().as_bytes(), + &member_id, &kp_bytes, )?; } @@ -314,16 +330,17 @@ impl GroupV2Convo { let outbound = self.conversation.drain_outbound(); // Vec let wakeup = self.conversation.next_wakeup_in(); - // 1. Route welcomes for joiners WE invited (event fires on every member now). + // 1. Route welcomes for joiners WE invited (event fires on every member + // now). The welcome travels to the joiner's signer id (where its + // InboxV2 listens), not its de-mls member id. for evt in &events { if let ConversationEvent::WelcomeReady { welcome, .. } = evt { for joiner in &welcome.joiner_identities { - if let Some(i) = self.pending_invites.iter().position(|p| p == joiner) { - self.pending_invites.remove(i); - let name = String::from_utf8(joiner.clone()).map_err(ChatError::generic)?; + if let Some(i) = self.pending_invites.iter().position(|(p, _)| p == joiner) { + let (_, signer_id) = self.pending_invites.remove(i); crate::inbox_v2::invite_user_v2( &mut service_ctx.ds, - &IdentId::new(name), + &IdentId::new(signer_id), welcome, )?; } diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 7f2d5e4..ca1c19b 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -14,7 +14,7 @@ use crate::{ }; use crypto::{Identity, PublicKey}; use openmls::group::GroupId; -use shared_traits::IdentIdRef; +use shared_traits::{IdentId, IdentIdRef}; use std::collections::HashMap; use std::fmt::Debug; use storage::{ChatStore, ConversationKind, ConversationStore}; @@ -97,7 +97,6 @@ where )?; core.register_keypackage()?; - core.register_account_bundle()?; Ok(core) } @@ -112,7 +111,12 @@ where store: CS, ) -> Result { let inbox = Inbox::new(&identity); - let ident_id = ident.id().clone(); + // InboxV2 rendezvous is signer-scoped: it subscribes under the hex of + // the signer's verifying key — the same string the account → device + // directory lists and the registries key key-packages under, so it is + // exactly what an inviter can derive for this installation. The MLS + // credential below still carries the full `id()`. + let ident_id = IdentId::new(hex::encode(ident.public_key().as_ref())); let mls_identity = MlsIdentityProvider::new(ident); let mls_provider = MlsEphemeralPqProvider::new().map_err(ChatError::generic)?; let causal = CausalHistoryStore::new(); @@ -153,19 +157,12 @@ impl<'a, S: ExternalServices + 'static> Core { &self.services.store } - /// The account → device directory (our account store). Used to verify that a - /// received message's claimed account actually endorses the sending device - /// before the message is surfaced. Exposed as `RegistrationService`, whose - /// `AccountDirectory` supertrait provides `fetch`. - pub fn account_directory(&self) -> &S::RS { - &self.services.registry - } - pub fn identity(&self) -> &Identity { &self.services.identity } - /// Returns the unique identifier associated with the account + /// The signer id this core receives InboxV2 invites under — the hex of the + /// signer's verifying key. pub fn ident_id(&'a self) -> IdentIdRef<'a> { self.pq_inbox.ident_id() } @@ -177,14 +174,6 @@ impl<'a, S: ExternalServices + 'static> Core { self.pq_inbox.register(&mut self.services) } - /// Publish this installation's device key into the account → device - /// directory, so inviters can resolve this account to its device(s). Pairs - /// with [`register_keypackage`](Self::register_keypackage); call both after - /// provisioning so the account is fully discoverable. - pub fn register_account_bundle(&mut self) -> Result<(), ChatError> { - self.pq_inbox.publish_device_bundle(&mut self.services) - } - pub fn installation_name(&self) -> &str { self.services.identity.get_name() } diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index e52605f..8de4340 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -2,7 +2,6 @@ mod identity; mod mls_provider; use chat_proto::logoschat::envelope::EnvelopeV1; -use crypto::Ed25519VerifyingKey; use de_mls::protos::de_mls::messages::v1::MemberWelcome; use openmls::prelude::tls_codec::Serialize; use openmls::prelude::*; @@ -23,11 +22,7 @@ use crate::conversation::GroupV2Convo; use crate::conversation::Identified as _; use crate::service_context::{ExternalServices, ServiceContext}; use crate::utils::{blake2b_hex, hash_size}; -use crate::{ - AccountAuthority, AccountDirectory, AddressedEnvelope, SignedDeviceBundle, - encode_bundle_payload, -}; -use crate::{IdentId, IdentIdRef, IdentityProvider}; +use crate::{AddressedEnvelope, IdentId, IdentIdRef, IdentityProvider}; // Downgraded from MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519 until demls accepts an external provider pub(crate) const CIPHER_SUITE: Ciphersuite = @@ -53,33 +48,34 @@ pub trait MlsProvider: OpenMlsProvider { ) -> Result<(), ChatError>; } -/// Deliver a de-mls welcome to `account_id` over its InboxV2 1-1 channel. +/// Deliver a de-mls welcome to `signer_id` over its InboxV2 1-1 channel. /// Function mirroring the GroupV1 `invite_user` path, but carrying a de-mls `MemberWelcome`. pub fn invite_user_v2( ds: &mut DS, - account_id: IdentIdRef, + signer_id: IdentIdRef, welcome: &MemberWelcome, ) -> Result<(), ChatError> { let frame = InboxV2Frame { payload: Some(InviteType::GroupV2(welcome.encode_to_vec())), }; let envelope = EnvelopeV1 { - conversation_hint: conversation_id_for(account_id), + conversation_hint: conversation_id_for(signer_id), salt: 0, payload: frame.encode_to_vec().into(), }; ds.publish(AddressedEnvelope { - delivery_address: delivery_address_for(account_id), + delivery_address: delivery_address_for(signer_id), data: envelope.encode_to_vec(), }) .map_err(ChatError::generic) } -/// An PQ focused Conversation initializer. -/// InboxV2 Incorporates an Account based identity system to support PQ based conversation protocols -/// such as MLS. +/// A PQ focused Conversation initializer. +/// InboxV2 is signer-scoped: it receives invites under this installation's +/// signer id (the hex of the signer's verifying key), supporting PQ based +/// conversation protocols such as MLS. pub struct InboxV2 { - // Account_id field is an owned value, so it can be returned via reference. + // Owned so it can be returned via reference. ident_id: IdentId, } @@ -205,78 +201,6 @@ impl InboxV2 { } } -// Publishing the account → device bundle needs the account key, so this method -// is available only when the registry also implements `AccountDirectory`. The -// signing authority is the `LogosAccount` wrapped by `mls_identity`; on testnet -// that is a local key (account key == device key), while an external signer -// would supply its own authority. -impl InboxV2 { - /// Add this installation's device key to the account's directory bundle. - /// - /// Fetches the current (verified) device set, adds this device if absent, - /// bumps the lamport, re-signs with the account key, and publishes. Safe to - /// call repeatedly — an unchanged set is simply re-published, which also - /// refreshes the server's retention clock. - pub fn publish_device_bundle( - &self, - cx: &mut ServiceContext, - ) -> Result<(), ChatError> { - // On testnet `mls_identity` doubles as the `AccountAuthority` — the - // account key is the installation's own key. - let authority = &cx.mls_identity; - - let account_pub = AccountAuthority::account_pub(authority).clone(); - let device_key = cx.mls_identity.public_key().clone(); - let device_hex = hex::encode(device_key.as_ref()); - - // Start from the devices already registered so other installations of - // this account are preserved across the upsert. - let existing = cx - .registry - .fetch(&account_pub) - .map_err(|e| ChatError::Generic(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_else(|| { - ChatError::Generic("directory returned a malformed device id".into()) - })?; - let key = Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| { - ChatError::Generic("directory returned a malformed device key".into()) - })?; - keys.push(key); - } - (keys, set.lamport + 1) - } - None => (Vec::new(), 0), - }; - - if !devices - .iter() - .any(|d| hex::encode(d.as_ref()) == device_hex) - { - devices.push(device_key); - } - - let payload = encode_bundle_payload(next_lamport, &devices); - let signature = AccountAuthority::sign(authority, &payload) - .map_err(|e| ChatError::Generic(e.to_string()))?; - let bundle = SignedDeviceBundle { - account_pub, - payload, - signature, - }; - - cx.registry - .publish(&bundle) - .map_err(|e| ChatError::Generic(e.to_string())) - } -} - #[derive(Clone, PartialEq, Message)] pub struct InboxV2Frame { #[prost(oneof = "InviteType", tags = "1, 2")] diff --git a/core/conversations/src/inbox_v2/identity.rs b/core/conversations/src/inbox_v2/identity.rs index 779f90b..7691cc4 100644 --- a/core/conversations/src/inbox_v2/identity.rs +++ b/core/conversations/src/inbox_v2/identity.rs @@ -1,6 +1,5 @@ use std::ops::Deref; -use crypto::{Ed25519Signature, Ed25519VerifyingKey}; use openmls::credentials::{BasicCredential, CredentialWithKey}; use openmls_traits::{ signatures::{Signer, SignerError}, @@ -8,7 +7,6 @@ use openmls_traits::{ }; use shared_traits::IdentIdRef; -use crate::AccountAuthority; use crate::IdentityProvider; /// A Wrapper for an IdentityProvider which provides MLS specific functionality @@ -57,22 +55,6 @@ impl IdentityProvider for MlsIdentityProvider { } } -// On testnet the installation identity is also the account authority: the -// account key is the installation's own key, so the device bundle is signed and -// addressed under `public_key()`. A real deployment injects a separate -// `AccountAuthority` (wallet/enclave) whose key custody lives outside libchat. -impl AccountAuthority for MlsIdentityProvider { - type Error = std::convert::Infallible; - - fn account_pub(&self) -> &Ed25519VerifyingKey { - self.public_key() - } - - fn sign(&self, payload: &[u8]) -> Result { - Ok(IdentityProvider::sign(self, payload)) - } -} - // Implement Signer directly for MlsIdentityProvider, so that openmls Signer contstraint // does not leave the module. impl Signer for MlsIdentityProvider { diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index f247933..654630e 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -1,4 +1,3 @@ -mod account_directory; mod causal_history; mod conversation; mod core; @@ -13,11 +12,6 @@ mod service_traits; mod types; mod utils; -pub use account_directory::{ - AccountAuthority, AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, - DeviceSet, Lamport, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload, - resolve_device_ids, verify_bundle, -}; pub use causal_history::{Frontier, MissingMessage}; pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; diff --git a/core/conversations/src/service_context.rs b/core/conversations/src/service_context.rs index eb03210..82cf1d3 100644 --- a/core/conversations/src/service_context.rs +++ b/core/conversations/src/service_context.rs @@ -49,10 +49,8 @@ pub(crate) struct ServiceContext { #[cfg(test)] mod test_support { use super::*; - use crate::account_directory::{AccountDirectory, DeviceSet, SignedDeviceBundle}; use crate::types::AddressedEnvelope; use crate::{ChatError, IdentityProvider}; - use crypto::Ed25519VerifyingKey; /// Delivery double that drops every payload. #[derive(Debug)] @@ -81,32 +79,11 @@ mod test_support { &mut self, _identity: &dyn IdentityProvider, _key_bundle: Vec, - ) -> Result<(), ::Error> { + ) -> Result<(), Self::Error> { Ok(()) } - fn retrieve( - &self, - _device_id: &str, - ) -> Result>, ::Error> { - Ok(None) - } - } - - impl AccountDirectory for NoopRegistration { - type Error = std::convert::Infallible; - - fn publish( - &mut self, - _bundle: &SignedDeviceBundle, - ) -> Result<(), ::Error> { - Ok(()) - } - - fn fetch( - &self, - _account: &Ed25519VerifyingKey, - ) -> Result, ::Error> { + fn retrieve(&self, _device_id: &str) -> Result>, Self::Error> { Ok(None) } } diff --git a/core/conversations/src/service_traits.rs b/core/conversations/src/service_traits.rs index 3e4b886..dac2516 100644 --- a/core/conversations/src/service_traits.rs +++ b/core/conversations/src/service_traits.rs @@ -7,7 +7,7 @@ use std::{ time::Duration, }; -use crate::{AccountDirectory, ConversationId, types::AddressedEnvelope}; +use crate::{ConversationId, types::AddressedEnvelope}; /// A Delivery service is responsible for payload transport. /// This interface allows Conversations to send payloads on the wire as well as @@ -29,25 +29,14 @@ pub trait DeliveryService: Debug { /// implementations that need to authenticate the submission — e.g. a network /// service that verifies the bundle is signed by the correct account — can /// sign or attest with the caller's key material. -/// -/// On testnet a single service (the keypackage-registry) provides both the -/// keypackage store and the account → device directory, so [`AccountDirectory`] -/// is a supertrait: any `RegistrationService` also resolves accounts to devices. -/// This co-location is intentional and temporary; the two can be split into -/// separate injected services once λLEZ lands. -pub trait RegistrationService: Debug + AccountDirectory { - // Disambiguated below: with `AccountDirectory` as a supertrait, a bare - // `Self::Error` is ambiguous between the two traits' associated types. +pub trait RegistrationService: Debug { type Error: Display + Debug; fn register( &mut self, identity: &dyn IdentityProvider, key_bundle: Vec, - ) -> Result<(), ::Error>; - fn retrieve( - &self, - device_id: &str, - ) -> Result>, ::Error>; + ) -> Result<(), Self::Error>; + fn retrieve(&self, device_id: &str) -> Result>, Self::Error>; } /// Read-only view of a contact registry. Not part of the public API. @@ -58,9 +47,7 @@ pub trait KeyPackageProvider: Debug { } impl KeyPackageProvider for T { - // Disambiguate: `RegistrationService` now has `AccountDirectory` as a - // supertrait, so both expose an associated `Error`. - type Error = ::Error; + type Error = T::Error; fn retrieve(&self, device_id: &str) -> Result>, Self::Error> { RegistrationService::retrieve(self, device_id) } diff --git a/core/integration_tests_core/Cargo.toml b/core/integration_tests_core/Cargo.toml index 8a5cabf..462b701 100644 --- a/core/integration_tests_core/Cargo.toml +++ b/core/integration_tests_core/Cargo.toml @@ -10,8 +10,8 @@ edition = "2024" # Workspace dependencies (sorted) chat-sqlite = { workspace = true } components = { workspace = true } +crypto = { workspace = true } libchat = { workspace = true } -logos-account = { workspace = true, features = ["dev"]} shared-traits = { workspace = true } # External dependencies (sorted) diff --git a/core/integration_tests_core/src/lib.rs b/core/integration_tests_core/src/lib.rs index 0c2e590..83db65e 100644 --- a/core/integration_tests_core/src/lib.rs +++ b/core/integration_tests_core/src/lib.rs @@ -1,4 +1,6 @@ mod test_client; +mod test_ident; mod wakeup; pub use test_client::TestHarness; +pub use test_ident::TestIdent; diff --git a/core/integration_tests_core/src/test_client.rs b/core/integration_tests_core/src/test_client.rs index 3843f93..09c84ef 100644 --- a/core/integration_tests_core/src/test_client.rs +++ b/core/integration_tests_core/src/test_client.rs @@ -1,5 +1,5 @@ +use crate::test_ident::TestIdent; use libchat::{ConversationId, Core, IdentityProvider, PayloadOutcome}; -use logos_account::TestLogosAccount; use shared_traits::IdentId; use std::collections::HashMap; use std::fmt::Debug; @@ -21,14 +21,8 @@ const RAYA: usize = 1; const PAX: usize = 2; const MIRA: usize = 3; -// type ClientType = CoreClient; -type ClientType = Core<( - TestLogosAccount, - LocalBroadcaster, - EphemeralRegistry, - WP, - MemStore, -)>; +// type ClientType = CoreClient; +type ClientType = Core<(TestIdent, LocalBroadcaster, EphemeralRegistry, WP, MemStore)>; #[derive(Debug)] pub struct ReceivedMessage { @@ -151,7 +145,7 @@ impl TestHarness { for i in 0..N { let wp = ws.new_provider(i); - let ident = TestLogosAccount::new(Self::names(i)); + let ident = TestIdent::new(Self::names(i)); addresses.insert(i, ident.id().clone()); let core_client = diff --git a/core/integration_tests_core/src/test_ident.rs b/core/integration_tests_core/src/test_ident.rs new file mode 100644 index 0000000..20bf23b --- /dev/null +++ b/core/integration_tests_core/src/test_ident.rs @@ -0,0 +1,41 @@ +use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; +use libchat::IdentityProvider; +use shared_traits::{IdentId, IdentIdRef}; + +/// Test identity with a fixed, human-readable id ("saro"). Stands in for a +/// device signer so core tests can address peers by name. +pub struct TestIdent { + id: IdentId, + signing_key: Ed25519SigningKey, + verifying_key: Ed25519VerifyingKey, +} + +impl TestIdent { + pub fn new(explicit_id: impl Into) -> Self { + let signing_key = Ed25519SigningKey::generate(); + let verifying_key = signing_key.verifying_key(); + Self { + id: IdentId::new(explicit_id.into()), + signing_key, + verifying_key, + } + } +} + +impl IdentityProvider for TestIdent { + fn id(&self) -> IdentIdRef<'_> { + &self.id + } + + fn display_name(&self) -> String { + self.id.to_string() + } + + fn public_key(&self) -> &Ed25519VerifyingKey { + &self.verifying_key + } + + fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature { + self.signing_key.sign(payload) + } +} diff --git a/core/integration_tests_core/tests/causal_history.rs b/core/integration_tests_core/tests/causal_history.rs index 4757d55..434fad0 100644 --- a/core/integration_tests_core/tests/causal_history.rs +++ b/core/integration_tests_core/tests/causal_history.rs @@ -7,8 +7,8 @@ use std::ops::{Deref, DerefMut}; use components::{EphemeralRegistry, LocalBroadcaster, MemStore}; +use integration_tests_core::TestIdent; use libchat::{Core, MissingMessage, WakeupService}; -use logos_account::TestLogosAccount; #[derive(Debug)] struct NoopWakeupService {} @@ -18,7 +18,7 @@ impl WakeupService for NoopWakeupService { struct Client { inner: Core<( - TestLogosAccount, + TestIdent, LocalBroadcaster, EphemeralRegistry, NoopWakeupService, @@ -29,7 +29,7 @@ struct Client { impl Client { fn init( core: Core<( - TestLogosAccount, + TestIdent, LocalBroadcaster, EphemeralRegistry, NoopWakeupService, @@ -60,7 +60,7 @@ impl Client { impl Deref for Client { type Target = Core<( - TestLogosAccount, + TestIdent, LocalBroadcaster, EphemeralRegistry, NoopWakeupService, @@ -82,9 +82,9 @@ fn missing_group_message_is_detected() { let ds = LocalBroadcaster::new(); let rs = EphemeralRegistry::new(); - let saro_account = TestLogosAccount::new("saro"); + let saro_ident = TestIdent::new("saro"); let saro_ctx = Core::new_with_name( - saro_account, + saro_ident, ds.new_consumer(), rs.clone(), NoopWakeupService {}, @@ -92,9 +92,9 @@ fn missing_group_message_is_detected() { ) .unwrap(); - let raya_account = TestLogosAccount::new("raya"); + let raya_ident = TestIdent::new("raya"); let raya_ctx = Core::new_with_name( - raya_account, + raya_ident, ds.clone(), rs.clone(), NoopWakeupService {}, @@ -135,9 +135,11 @@ fn missing_group_message_is_detected() { !missing[0].frontier.message_id().is_empty(), "the missing message must be identified" ); + // The causal sender hint carries the sender's identity id ("saro"), not + // the signer id the inbox and registry key on. assert_eq!( missing[0].frontier.sender_id(), - saro.ident_id().as_str(), + "saro", "missing-message sender hint should attribute to Saro" ); diff --git a/core/integration_tests_core/tests/private_integration.rs b/core/integration_tests_core/tests/private_integration.rs index 55819e0..c620f29 100644 --- a/core/integration_tests_core/tests/private_integration.rs +++ b/core/integration_tests_core/tests/private_integration.rs @@ -1,6 +1,6 @@ use chat_sqlite::{ChatStorage, StorageConfig}; +use integration_tests_core::TestIdent; use libchat::{ConversationClass, Core, Introduction, PayloadOutcome, WakeupService}; -use logos_account::TestLogosAccount; use storage::{ConversationStore, IdentityStore}; use tempfile::tempdir; @@ -13,7 +13,7 @@ impl WakeupService for NoopWakeupService { } type PrivateCore = Core<( - TestLogosAccount, + TestIdent, LocalBroadcaster, EphemeralRegistry, NoopWakeupService, @@ -62,18 +62,18 @@ fn ctx_integration() { let ds = LocalBroadcaster::new(); let rs = EphemeralRegistry::new(); - let saro_account = TestLogosAccount::new("saro"); + let saro_ident = TestIdent::new("saro"); let mut saro = Core::new_with_name( - saro_account, + saro_ident, ds.clone(), rs.clone(), NoopWakeupService {}, ChatStorage::in_memory(), ) .unwrap(); - let raya_account = TestLogosAccount::new("raya"); + let raya_ident = TestIdent::new("raya"); let mut raya = Core::new_with_name( - raya_account, + raya_ident, ds, rs, NoopWakeupService {}, @@ -121,8 +121,8 @@ fn identity_persistence() { let ds = LocalBroadcaster::new(); let rs = EphemeralRegistry::new(); let store1 = ChatStorage::new(StorageConfig::InMemory).unwrap(); - let alice_account = TestLogosAccount::new("alice"); - let ctx1 = Core::new_with_name(alice_account, ds, rs, NoopWakeupService {}, store1).unwrap(); + let alice_ident = TestIdent::new("alice"); + let ctx1 = Core::new_with_name(alice_ident, ds, rs, NoopWakeupService {}, store1).unwrap(); let pubkey1 = ctx1.identity().public_key(); let name1 = ctx1.installation_name().to_string(); @@ -141,8 +141,8 @@ fn open_persists_new_identity() { let ds = LocalBroadcaster::new(); let rs = EphemeralRegistry::new(); let store = ChatStorage::new(StorageConfig::File(db_path.clone())).unwrap(); - let alice_account = TestLogosAccount::new("alice"); - let core = Core::new_from_store(alice_account, ds, rs, NoopWakeupService {}, store).unwrap(); + let alice_ident = TestIdent::new("alice"); + let core = Core::new_from_store(alice_ident, ds, rs, NoopWakeupService {}, store).unwrap(); let pubkey = core.identity().public_key(); drop(core); @@ -157,18 +157,18 @@ fn open_persists_new_identity() { fn conversation_metadata_persistence() { let ds = LocalBroadcaster::new(); let rs = EphemeralRegistry::new(); - let alice_account = TestLogosAccount::new("alice"); + let alice_ident = TestIdent::new("alice"); let mut alice = Core::new_with_name( - alice_account, + alice_ident, ds.clone(), rs.clone(), NoopWakeupService {}, ChatStorage::in_memory(), ) .unwrap(); - let bob_account = TestLogosAccount::new("bob"); + let bob_ident = TestIdent::new("bob"); let mut bob = Core::new_with_name( - bob_account, + bob_ident, ds, rs, NoopWakeupService {}, @@ -198,18 +198,18 @@ fn conversation_metadata_persistence() { fn conversation_full_flow() { let ds = LocalBroadcaster::new(); let rs = EphemeralRegistry::new(); - let alice_account = TestLogosAccount::new("alice"); + let alice_ident = TestIdent::new("alice"); let mut alice = Core::new_with_name( - alice_account, + alice_ident, ds.clone(), rs.clone(), NoopWakeupService {}, ChatStorage::in_memory(), ) .unwrap(); - let bob_account = TestLogosAccount::new("bob"); + let bob_ident = TestIdent::new("bob"); let mut bob = Core::new_with_name( - bob_account, + bob_ident, ds, rs, NoopWakeupService {}, diff --git a/crates/client/examples/message-exchange/main.rs b/crates/client/examples/message-exchange/main.rs index ea755d5..a51cc34 100644 --- a/crates/client/examples/message-exchange/main.rs +++ b/crates/client/examples/message-exchange/main.rs @@ -1,4 +1,5 @@ use components::EphemeralRegistry; +use logos_account::TestLogosAccount; use logos_chat::{ChatClientBuilder, Event, InProcessDelivery, MessageBus}; use std::time::Duration; @@ -6,13 +7,13 @@ fn main() { let bus = MessageBus::default(); let reg = EphemeralRegistry::new(); - let (mut saro, saro_events) = ChatClientBuilder::new() + let (mut saro, saro_events) = ChatClientBuilder::new(TestLogosAccount::new().address()) .transport(InProcessDelivery::new(bus.clone())) .registration(reg.clone()) .build() .unwrap(); - let (mut raya, raya_events) = ChatClientBuilder::new() + let (mut raya, raya_events) = ChatClientBuilder::new(TestLogosAccount::new().address()) .transport(InProcessDelivery::new(bus)) .registration(reg) .build() diff --git a/crates/client/src/builder.rs b/crates/client/src/builder.rs index e093dfe..6a66394 100644 --- a/crates/client/src/builder.rs +++ b/crates/client/src/builder.rs @@ -1,6 +1,7 @@ use components::EphemeralRegistry; use crossbeam_channel::Receiver; -use libchat::{ChatError, ChatStorage, IdentityProvider, RegistrationService, StorageConfig}; +use libchat::{ChatError, ChatStorage, RegistrationService, StorageConfig}; +use logos_account::AccountDirectory; use storage::ChatStore; use crate::Transport; @@ -15,15 +16,22 @@ pub struct Unset; pub struct ChatClientBuilder { ident: I, + account: String, transport: T, registration: R, storage: S, } -impl Default for ChatClientBuilder { - fn default() -> Self { +impl ChatClientBuilder { + /// Every client acts for an account, so the builder starts from its + /// address. It becomes 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 { Self { ident: Unset, + account: account.into(), transport: Unset, registration: Unset, storage: Unset, @@ -31,16 +39,11 @@ impl Default for ChatClientBuilder { } } -impl ChatClientBuilder { - pub fn new() -> Self { - Self::default() - } -} - impl ChatClientBuilder { - pub fn ident(self, ident: NI) -> ChatClientBuilder { + pub fn ident(self, ident: DelegateSigner) -> ChatClientBuilder { ChatClientBuilder { ident, + account: self.account, transport: self.transport, registration: self.registration, storage: self.storage, @@ -50,6 +53,7 @@ impl ChatClientBuilder { pub fn transport(self, transport: NT) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, + account: self.account, transport, registration: self.registration, storage: self.storage, @@ -59,6 +63,7 @@ impl ChatClientBuilder { pub fn registration(self, registration: NR) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, + account: self.account, transport: self.transport, registration, storage: self.storage, @@ -68,6 +73,7 @@ impl ChatClientBuilder { pub fn storage(self, storage: NS) -> ChatClientBuilder { ChatClientBuilder { ident: self.ident, + account: self.account, transport: self.transport, registration: self.registration, storage, @@ -81,6 +87,7 @@ impl ChatClientBuilder { ChatClientBuilder { ident: self.ident, + account: self.account, transport: self.transport, registration: self.registration, storage, @@ -88,26 +95,32 @@ impl ChatClientBuilder { } } -type Built = Result<(ChatClient, Receiver), ClientError>; +type Built = Result<(ChatClient, Receiver), ClientError>; // All four explicitly provided. -impl ChatClientBuilder +impl ChatClientBuilder where - I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { - ChatClient::new(self.ident, self.transport, self.registration, self.storage) + pub fn build(self) -> Built { + ChatClient::new( + self.ident, + self.account, + self.transport, + self.registration, + self.storage, + ) } } // Transport only; I, R, S all default. impl ChatClientBuilder { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.account, self.transport, EphemeralRegistry::new(), ChatStorage::in_memory(), @@ -116,14 +129,14 @@ impl ChatClientBuilder { } // I and T; R and S default. -impl ChatClientBuilder +impl ChatClientBuilder where - I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, + self.account, self.transport, EphemeralRegistry::new(), ChatStorage::in_memory(), @@ -135,11 +148,12 @@ where impl ChatClientBuilder where T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.account, self.transport, self.registration, ChatStorage::in_memory(), @@ -153,9 +167,10 @@ where T: Transport + Send + 'static, S: ChatStore + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( DelegateSigner::random(), + self.account, self.transport, EphemeralRegistry::new(), self.storage, @@ -164,15 +179,15 @@ where } // I, T, and R; S defaults. -impl ChatClientBuilder +impl ChatClientBuilder where - I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, { - pub fn build(self) -> Built { + pub fn build(self) -> Built { ChatClient::new( self.ident, + self.account, self.transport, self.registration, ChatStorage::in_memory(), @@ -184,12 +199,13 @@ where impl ChatClientBuilder where T: Transport + Send + 'static, - R: RegistrationService + 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.account, self.transport, self.registration, self.storage, @@ -198,15 +214,15 @@ where } // I, T, and S; R defaults. -impl ChatClientBuilder +impl ChatClientBuilder where - I: IdentityProvider + 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.account, self.transport, EphemeralRegistry::new(), self.storage, diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 13c5449..d425cd3 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -5,17 +5,18 @@ use components::{ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; use crypto::Ed25519VerifyingKey; use libchat::{ - AccountDirectory, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, IdentIdRef, - IdentityProvider, InboxOutcome, Introduction, PayloadOutcome, RegistrationService, + ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, IdentIdRef, InboxOutcome, + Introduction, PayloadOutcome, RegistrationService, }; +use logos_account::{AccountDirectory, resolve_device_ids}; use parking_lot::Mutex; use storage::ChatStore; -use crate::delegate::DelegateCredential; +use crate::delegate::{DelegateCredential, DelegateIdentity, DelegateSigner}; use crate::errors::ClientError; use crate::event::{Event, MessageSender}; -type ClientCore = Core<(I, T, R, ThreadedWakeupService, S)>; +type ClientCore = Core<(DelegateIdentity, T, R, ThreadedWakeupService, S)>; type AccountAddressRef<'a> = &'a str; type LocalSignerId = IdentId; @@ -40,16 +41,19 @@ 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 - I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { /// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't /// starve caller operations of the lock. - core: Arc>>, + core: Arc>>, + /// 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. + directory: R, /// Dropped on `Drop` to wake the worker's `select!` and shut it down. shutdown: Option>, worker: Option>, @@ -57,15 +61,15 @@ where } // -- GenericChatClient -impl ChatClient +impl ChatClient where - I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { pub fn new( - ident: I, + ident: DelegateSigner, + account: String, mut transport: T, reg: R, storage: S, @@ -74,28 +78,42 @@ where let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded(); let wakeup_service = ThreadedWakeupService::new(wakeup_tx); + let directory = reg.clone(); + let ident = DelegateIdentity::new(ident, &account); let core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?; - Ok(Self::spawn(core, inbound, wakeup_rx)) + Ok(Self::spawn(core, directory, account, inbound, wakeup_rx)) } fn spawn( - core: ClientCore, + core: ClientCore, + directory: R, + address: String, inbound: Receiver>, wakeup_events: Receiver, ) -> (Self, Receiver) { - let address = core.ident_id().to_string(); let core = Arc::new(Mutex::new(core)); let (event_tx, event_rx) = crossbeam_channel::unbounded(); let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded::<()>(0); let worker = thread::spawn({ let core = Arc::clone(&core); - move || worker_loop(core, inbound, wakeup_events, shutdown_rx, event_tx) + let directory = directory.clone(); + move || { + worker_loop( + core, + directory, + inbound, + wakeup_events, + shutdown_rx, + event_tx, + ) + } }); ( Self { core, + directory, shutdown: Some(shutdown_tx), worker: Some(worker), address, @@ -104,7 +122,8 @@ where ) } - pub fn addr(&self) -> AccountAddressRef<'_> { + /// The account address peers use to reach this client. + pub fn addr(&self) -> &str { &self.address } @@ -162,21 +181,24 @@ where .map_err(Into::into) } - // Get signers for a given AccountAddress. + /// Resolve an account address to the signer (device) ids its published + /// directory bundle endorses. A reachable account has published at least + /// one signer; anything else is an error. fn signers_from_account( &self, account: AccountAddressRef, ) -> Result, ClientError> { - // Assume Account = LocalSigner until Account is ready - Ok(vec![IdentId::new(account.to_string())]) + let account = IdentId::new(account.to_string()); + 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()) } } -impl Drop for ChatClient +impl Drop for ChatClient where - I: IdentityProvider + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Clone + Send + 'static, S: ChatStore + Send + 'static, { fn drop(&mut self) { @@ -192,15 +214,16 @@ where /// Background loop: block until an inbound payload or shutdown arrives, drive /// the core on each payload, and forward events. No polling — `select!` parks /// the thread until one of the channels is ready. -fn worker_loop( - core: Arc>>, +fn worker_loop( + core: Arc>>, + directory: R, inbound: Receiver>, wakeup_events: Receiver, shutdown: Receiver<()>, event_tx: Sender, ) where T: DeliveryService + Send + 'static, - R: RegistrationService + Send + 'static, + R: RegistrationService + AccountDirectory + Send + 'static, { loop { select! { @@ -211,7 +234,7 @@ fn worker_loop( let events = { let mut core = core.lock(); match core.handle_payload(&bytes) { - Ok(outcome) => events_from_inbound(outcome, core.account_directory()), + Ok(outcome) => events_from_inbound(outcome, &directory), Err(e) => { tracing::warn!("inbound handle_payload failed: {e:?}"); vec![Event::InboundError { @@ -371,7 +394,8 @@ mod sender_check_tests { use std::collections::HashMap; use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; - use libchat::{DeviceSet, IdentId, SignedDeviceBundle}; + use libchat::IdentId; + use logos_account::{DeviceSet, SignedDeviceBundle}; use super::{MessageSender, SenderError, decode_sender}; use crate::delegate::DelegateCredential; @@ -399,7 +423,7 @@ mod sender_check_tests { } } - impl libchat::AccountDirectory for FakeDir { + impl logos_account::AccountDirectory for FakeDir { type Error = &'static str; fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> { diff --git a/crates/client/src/delegate.rs b/crates/client/src/delegate.rs index c2cc712..a8ea88c 100644 --- a/crates/client/src/delegate.rs +++ b/crates/client/src/delegate.rs @@ -5,15 +5,12 @@ use crate::ClientError; type AccountAddr = String; -/// A local signing identity that holds an Ed25519 keypair. -/// -/// Can be standalone (unassociated) or authorized to act on behalf of an account -/// via [`DelegateSigner::associate`]. +/// 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`]). pub struct DelegateSigner { signing_key: Ed25519SigningKey, verifying_key: Ed25519VerifyingKey, - identifier: IdentId, - account_addr: Option, } impl DelegateSigner { @@ -21,28 +18,40 @@ impl DelegateSigner { pub fn random() -> Self { let signing_key = Ed25519SigningKey::generate(); let verifying_key = signing_key.verifying_key(); - let identifier = DelegateCredential::unassociated(&verifying_key).into(); Self { signing_key, verifying_key, - identifier, - account_addr: None, } } - /// Associate a DelegateSigner with an Account. - pub fn associate(&mut self, account_addr: AccountAddr) { - self.identifier = - DelegateCredential::associated(&self.verifying_key, account_addr.as_str()).into(); - self.account_addr = Some(account_addr); + pub fn public_key(&self) -> &Ed25519VerifyingKey { + &self.verifying_key } - pub fn account_addr(&self) -> Option<&str> { - self.account_addr.as_deref() + pub fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature { + self.signing_key.sign(payload) } } -impl IdentityProvider for DelegateSigner { +/// The identity the core sees: a [`DelegateSigner`] plus the wire credential +/// the client composed from it and its account address. The account +/// association is client state; neither the signer nor the core knows it. +pub(crate) struct DelegateIdentity { + signer: DelegateSigner, + identifier: IdentId, +} + +impl DelegateIdentity { + pub(crate) fn new(signer: DelegateSigner, account: &str) -> Self { + let credential = DelegateCredential::associated(signer.public_key(), account); + Self { + identifier: credential.into(), + signer, + } + } +} + +impl IdentityProvider for DelegateIdentity { fn id(&self) -> libchat::IdentIdRef<'_> { &self.identifier } @@ -52,11 +61,11 @@ impl IdentityProvider for DelegateSigner { } fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature { - self.signing_key.sign(payload) + self.signer.sign(payload) } fn public_key(&self) -> &Ed25519VerifyingKey { - &self.verifying_key + self.signer.public_key() } } diff --git a/crates/client/src/errors.rs b/crates/client/src/errors.rs index 18cd478..4e98c49 100644 --- a/crates/client/src/errors.rs +++ b/crates/client/src/errors.rs @@ -8,4 +8,8 @@ pub enum ClientError { BadlyFormedCredential, #[error("failed to start the transport: {0}")] Transport(String), + #[error("account resolution failed: {0}")] + AccountResolution(String), + #[error("device bundle publish failed: {0}")] + BundlePublish(String), } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index de0a5e3..3319397 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -23,6 +23,9 @@ pub use libchat::{ AddressedEnvelope, ChatStore, ConversationClass, ConversationId, DeliveryService, IdentityProvider, RegistrationService, StorageConfig, }; +// The directory trait bounds ChatClient's registry parameter, so callers +// writing code generic over ChatClient need it too. +pub use logos_account::AccountDirectory; // Re-export bundled registry implementations so callers can pick one without // pulling in `components` directly. diff --git a/crates/client/src/logos.rs b/crates/client/src/logos.rs index 214eda8..2fd34fb 100644 --- a/crates/client/src/logos.rs +++ b/crates/client/src/logos.rs @@ -16,6 +16,7 @@ use components::{EmbeddedP2pDeliveryService, HttpRegistry, P2pConfig}; use crossbeam_channel::Receiver; use libchat::{ChatStorage, StorageConfig}; +use logos_account::TestLogosAccount; use crate::ChatClientBuilder; use crate::client::{ChatClient, Transport}; @@ -34,11 +35,11 @@ impl Transport for EmbeddedP2pDeliveryService { } /// A [`ChatClient`] wired to the Logos service stack: a [`DelegateSigner`] -/// identity, the HTTP keypackage + account registry ([`HttpRegistry`], which is -/// both the keypackage store and the account → device directory), encrypted -/// [`ChatStorage`], and the logos-delivery transport. -pub type LogosChatClient = - ChatClient; +/// identity acting for a fresh dev account, the HTTP keypackage + account +/// registry ([`HttpRegistry`], which is both the keypackage store and the +/// account → device directory), encrypted [`ChatStorage`], and the +/// logos-delivery transport. +pub type LogosChatClient = ChatClient; impl LogosChatClient { /// Open a client on the Logos stack, starting a logos-delivery node on @@ -65,10 +66,20 @@ impl LogosChatClient { .map_err(|e| ClientError::Transport(e.to_string()))?; let endpoint = registry_url.unwrap_or(REGISTRY_ENDPOINT); - ChatClientBuilder::new() - .ident(DelegateSigner::random()) + // 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 + // this once the platform provides one. + let account = TestLogosAccount::new(); + let delegate = DelegateSigner::random(); + let mut registry = HttpRegistry::new(endpoint); + account + .add_delegate_signer(&mut registry, delegate.public_key()) + .map_err(|e| ClientError::BundlePublish(e.to_string()))?; + ChatClientBuilder::new(account.address()) + .ident(delegate) .transport(transport) - .registration(HttpRegistry::new(endpoint)) + .registration(registry) .storage_config(StorageConfig::Encrypted { path: db_path.into(), key: db_key.into(), diff --git a/crates/client/tests/saro_and_raya.rs b/crates/client/tests/saro_and_raya.rs index 091083c..ccc0916 100644 --- a/crates/client/tests/saro_and_raya.rs +++ b/crates/client/tests/saro_and_raya.rs @@ -3,7 +3,6 @@ use std::time::Duration; use components::EphemeralRegistry; use crossbeam_channel::{Receiver, Sender}; use crypto::Ed25519VerifyingKey; -use libchat::{AccountDirectory, IdentityProvider, SignedDeviceBundle, encode_bundle_payload}; use logos_account::TestLogosAccount; use logos_chat::{ AddressedEnvelope, ChatClient, ChatClientBuilder, DelegateSigner, DeliveryService, Event, @@ -17,29 +16,28 @@ fn publish_device_bundle( account: &TestLogosAccount, device: &Ed25519VerifyingKey, ) { - let payload = encode_bundle_payload(0, std::slice::from_ref(device)); - let signature = account.sign(&payload); - let bundle = SignedDeviceBundle { - account_pub: account.public_key().clone(), - payload, - signature, - }; - reg.publish(&bundle).unwrap(); + 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. #[allow(clippy::type_complexity)] fn create_test_client( message_bus: MessageBus, - reg: EphemeralRegistry, + mut reg: EphemeralRegistry, ) -> Result< ( - ChatClient, + ChatClient, Receiver, ), logos_chat::ClientError, > { + let account = TestLogosAccount::new(); + let delegate = DelegateSigner::random(); + publish_device_bundle(&mut reg, &account, delegate.public_key()); let d = InProcessDelivery::new(message_bus); - ChatClientBuilder::new() + ChatClientBuilder::new(account.address()) + .ident(delegate) .transport(d) .registration(reg) .build() @@ -91,24 +89,18 @@ fn direct_v1_standalone_integration() { let mut reg_service = EphemeralRegistry::new(); - // Create accounts and delegates, associate each delegate with its account - // address, and publish a device bundle so the receiver can verify the - // account → device mapping carried in the sender's credential. - let saro_account = TestLogosAccount::new("Saro"); - let saro_account_id = hex::encode(saro_account.public_key().as_ref()); - let mut saro_delegate = DelegateSigner::random(); - saro_delegate.associate(saro_account_id.clone()); + // Create accounts and delegates, and publish device bundles 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 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()); - let raya_account = TestLogosAccount::new("Raya"); - let mut raya_delegate = DelegateSigner::random(); - raya_delegate.associate(hex::encode(raya_account.public_key().as_ref())); - publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key()); - - // Build saro's client with its associated delegate so its outbound messages - // carry a credential the receiver can verify against the published bundle. - let (mut saro, _saro_events) = ChatClientBuilder::new() + // Build saro's client with its account so its outbound messages carry a + // credential the receiver can verify against the published bundle. + let (mut saro, _saro_events) = ChatClientBuilder::new(saro_account_id.clone()) .ident(saro_delegate) .transport(InProcessDelivery::new(bus.clone())) .registration(reg_service.clone()) @@ -146,6 +138,66 @@ fn direct_v1_standalone_integration() { }); } +/// A peer is reachable by its *account address* alone: the initiator resolves +/// the account to its signer ids through the directory (client layer), fetches +/// each signer's key package, and the Welcome arrives on the signer-scoped +/// inbox. The registry keys key packages by device id (hex verifying key), +/// exactly like the deployed HTTP registry. +#[test] +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 raya_delegate = DelegateSigner::random(); + publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key()); + + let (mut raya, raya_events) = ChatClientBuilder::new(raya_account_addr.clone()) + .ident(raya_delegate) + .transport(InProcessDelivery::new(bus.clone())) + .registration(reg_service.clone()) + .build() + .expect("client create"); + let (mut saro, saro_events) = + 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()); + let convo_id = saro.create_direct_conversation(&raya_account_addr).unwrap(); + + let raya_convo_id = expect_event(&raya_events, "ConversationStarted", |e| match e { + Event::ConversationStarted { convo_id, .. } => Ok(convo_id), + other => Err(other), + }); + + saro.send_message(&convo_id, b"hello raya").unwrap(); + expect_event(&raya_events, "MessageReceived", |e| match e { + Event::MessageReceived { content, .. } => { + assert_eq!(content.as_slice(), b"hello raya"); + Ok(()) + } + other => Err(other), + }); + + raya.send_message(&raya_convo_id, b"hi saro").unwrap(); + expect_event(&saro_events, "MessageReceived", |e| match e { + Event::MessageReceived { + content, sender, .. + } => { + assert_eq!(content.as_slice(), b"hi saro"); + // raya's bundle 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()) + ); + Ok(()) + } + other => Err(other), + }); +} + #[test] fn saro_raya_message_exchange() { let bus = MessageBus::default(); @@ -177,9 +229,9 @@ fn saro_raya_message_exchange() { } => { assert_eq!(convo_id, raya_convo_id); assert_eq!(content.as_slice(), b"hello raya"); - // saro's delegate is unassociated, so the sender surfaces its device - // but claims no account. - assert!(sender.account.is_none()); + // saro's account published a bundle endorsing its delegate, so the + // sender surfaces a verified account. + assert!(sender.account.is_some()); assert!(!sender.local_identity.as_str().is_empty()); Ok(()) } @@ -293,7 +345,7 @@ fn malformed_inbound_surfaces_as_error_event() { let delivery = FailingDelivery::new(); let inbound_tx = delivery.inbound_sender(); - let (_client, events) = ChatClientBuilder::new() + let (_client, events) = ChatClientBuilder::new(TestLogosAccount::new().address()) .transport(delivery) .build() .expect("client create"); @@ -308,3 +360,25 @@ fn malformed_inbound_surfaces_as_error_event() { other => Err(other), }); } + +/// Opening a conversation by an address whose account never published a +/// device bundle fails at resolution, not with a late key-package miss. +#[test] +fn unpublished_account_address_is_an_error() { + let bus = MessageBus::default(); + let reg_service = EphemeralRegistry::new(); + + let (mut saro, _saro_events) = + create_test_client(bus.clone(), reg_service.clone()).expect("client create"); + + let unpublished = TestLogosAccount::new(); + let err = saro + .create_direct_conversation(&unpublished.address()) + .expect_err("no bundle published for the account"); + assert!(matches!(err, logos_chat::ClientError::AccountResolution(_))); + + let err = saro + .create_direct_conversation("not-an-account-address") + .expect_err("not an account key"); + assert!(matches!(err, logos_chat::ClientError::AccountResolution(_))); +} diff --git a/extensions/components/Cargo.toml b/extensions/components/Cargo.toml index 1346cae..71092ca 100644 --- a/extensions/components/Cargo.toml +++ b/extensions/components/Cargo.toml @@ -11,6 +11,7 @@ embedded_p2p_delivery = [] # Workspace dependencies (sorted) crypto = { workspace = true } libchat = { workspace = true } +logos-account = { workspace = true } storage = { workspace = true } # External dependencies (sorted) diff --git a/extensions/components/src/contact_registry/ephemeral.rs b/extensions/components/src/contact_registry/ephemeral.rs index 8b28c70..f240ae2 100644 --- a/extensions/components/src/contact_registry/ephemeral.rs +++ b/extensions/components/src/contact_registry/ephemeral.rs @@ -5,10 +5,8 @@ use std::{ }; use crypto::Ed25519VerifyingKey; -use libchat::{ - AccountDirectory, DeviceSet, IdentityProvider, RegistrationService, SignedDeviceBundle, - verify_bundle, -}; +use libchat::{IdentityProvider, RegistrationService}; +use logos_account::{AccountDirectory, DeviceSet, SignedDeviceBundle, verify_bundle}; /// A Contact Registry used for Tests. /// This implementation stores bundle bytes and then returns them when @@ -61,10 +59,12 @@ impl RegistrationService for EphemeralRegistry { identity: &dyn IdentityProvider, key_bundle: Vec, ) -> Result<(), ::Error> { + // Keyed by device id — the hex of the signer's verifying key — exactly + // like the HTTP registry, so tests exercise the deployed keying. self.key_packages .lock() .unwrap() - .insert(identity.id().to_string(), key_bundle); + .insert(hex::encode(identity.public_key().as_ref()), key_bundle); Ok(()) } diff --git a/extensions/components/src/contact_registry/http.rs b/extensions/components/src/contact_registry/http.rs index 7238cab..e7955d5 100644 --- a/extensions/components/src/contact_registry/http.rs +++ b/extensions/components/src/contact_registry/http.rs @@ -4,10 +4,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use crypto::{Ed25519Signature, Ed25519VerifyingKey}; -use libchat::{ - AccountDirectory, BundleError, DeviceSet, IdentityProvider, RegistrationService, - SignedDeviceBundle, verify_bundle, -}; +use libchat::{IdentityProvider, RegistrationService}; +use logos_account::{AccountDirectory, BundleError, DeviceSet, SignedDeviceBundle, verify_bundle}; use serde::{Deserialize, Serialize}; /// HTTP client for the testnet KeyPackage Registry service.