diff --git a/core/account/src/credential.rs b/core/account/src/credential.rs index 395b655..5bcf27e 100644 --- a/core/account/src/credential.rs +++ b/core/account/src/credential.rs @@ -1,50 +1,71 @@ -//! Account ↔ LocalIdentity binding carried inside the MLS leaf credential. +//! The MLS leaf credential as a plain Account claim, plus the service that +//! validates it. //! -//! A group message in MLS is signed by a per-device key — the **LocalIdentity** -//! (a device/installation). On its own that key says nothing about which -//! **Account** the device belongs to, so multiple LocalIdentities of one Account -//! cannot be collapsed back to that Account on the receiving side. +//! Two identity scopes meet in a group message: //! -//! This module binds the two together *inside the credential itself*, so a -//! receiver resolves both without any network round-trip or trusted directory: +//! - **Signer** — the device key. MLS proves the sender controls it; it *is* the +//! LocalIdentity. Surfaced as the leaf's signature key. +//! - **Credential** — a public *claim* `(AccountId, device public key)`. The +//! credential content carries only the claimed Account; the device key is the +//! leaf signature key MLS hands us. //! -//! - the MLS leaf's signature key is the LocalIdentity device key (MLS already -//! proves the sender holds its private half), and -//! - the credential content carries the Account key plus the Account's signature -//! endorsing that exact device key. -//! -//! [`resolve_sender`] verifies the endorsement against the device key the leaf -//! actually signs with, yielding a [`MessageSender`] the application can trust. -//! This is the inverse of the account → device directory in `libchat` -//! (`account_directory`): that resolves an Account *to* its devices for invites; -//! this resolves a device *back to* its Account on receipt. +//! A claim is not trusted on its own — anyone can staple any account id next to +//! their own device key. Trust comes from an account service (the account → +//! device directory): it answers "is this device key actually registered to +//! that account?". Decoding ([`decode_credential`]) is therefore separate from +//! validation: core surfaces the raw [`SenderCredential`], and the client +//! validates it before reporting an identifier to the application. -use crypto::{Ed25519Signature, Ed25519VerifyingKey}; +use crypto::Ed25519VerifyingKey; use shared_traits::IdentId; use thiserror::Error; /// Current credential content version. Bump when [`encode_credential`] changes. pub const CREDENTIAL_VERSION: u8 = 1; -/// Domain-separation tag prepended to the credential content and folded into the -/// endorsement message. The account key may sign other things (e.g. the device -/// bundle in `account_directory`), so binding to this exact purpose stops a -/// signature obtained elsewhere from being replayed here. The trailing NUL keeps -/// it from being a prefix of any other domain. +/// Domain-separation tag prepended to the credential content, so these bytes +/// can't be confused with any other signed/encoded payload in the system. The +/// trailing NUL keeps it from being a prefix of any other domain. pub const CREDENTIAL_DOMAIN: &[u8] = b"libchat:account-local-identity\0"; -/// The verified sender of a group message: which Account, and which device -/// (LocalIdentity) of that Account it was sent from. Both are hex-encoded -/// Ed25519 verifying keys. +/// The raw, *unvalidated* sender of a group message, decoded from the MLS +/// credential: the claimed Account and the device (LocalIdentity) it was sent +/// from. Both are hex-encoded Ed25519 verifying keys. +/// +/// The `local_identity` is trustworthy on its own — MLS verified the message +/// against that device key. The `account` is only a *claim* until an +/// [`AccountService`] confirms the device belongs to it. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct MessageSender { - /// The Account the sending device belongs to. +pub struct SenderCredential { + /// The Account the sender *claims* to belong to (hex of the account key). pub account: IdentId, - /// The specific LocalIdentity (device/installation) that sent the message. + /// The device/LocalIdentity that sent the message (hex of the leaf key). pub local_identity: IdentId, } -/// Failures decoding or verifying an account-bound credential. +/// The validated identifier handed to the application: a [`SenderCredential`] +/// whose account claim an [`AccountService`] has confirmed. Same shape as the +/// credential, but the distinct type marks that validation has happened. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MessageSender { + /// The confirmed Account the sending device belongs to. + pub account: IdentId, + /// The specific LocalIdentity (device) that sent the message. + pub local_identity: IdentId, +} + +impl MessageSender { + /// Promote a validated credential to an identifier. Call only *after* an + /// [`AccountService`] has confirmed the claim. + pub fn validated(cred: SenderCredential) -> Self { + Self { + account: cred.account, + local_identity: cred.local_identity, + } + } +} + +/// Failures decoding a credential. #[derive(Debug, Error)] pub enum CredentialError { #[error("credential is missing the account-local-identity domain prefix")] @@ -55,62 +76,35 @@ pub enum CredentialError { Version(u8), #[error("credential carries a malformed account key")] AccountKey, - #[error("account endorsement of the local identity failed verification")] - SignatureInvalid, } -/// The bytes the Account signs to endorse `device` as one of its LocalIdentities. +/// Encode the MLS credential content: the Account claim. /// -/// `account` is folded in alongside the device key so the signature is bound to -/// the specific (account, device) pair, not just the device key in isolation. -fn endorsement_message(account: &Ed25519VerifyingKey, device: &Ed25519VerifyingKey) -> Vec { - let mut msg = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 32 + 32); - msg.extend_from_slice(CREDENTIAL_DOMAIN); - msg.extend_from_slice(account.as_ref()); - msg.extend_from_slice(device.as_ref()); - msg -} - -/// Produce the Account's endorsement of `device`, using the Account's signing -/// capability. `sign` is the account authority's signer (a local key on testnet, -/// or an external wallet/enclave), so the closure is fallible. -pub fn endorse_local_identity( - account: &Ed25519VerifyingKey, - device: &Ed25519VerifyingKey, - sign: impl FnOnce(&[u8]) -> Result, -) -> Result { - sign(&endorsement_message(account, device)) -} - -/// Encode the MLS credential content that binds a LocalIdentity to its Account. -/// -/// The device key is *not* embedded: it is the leaf's signature key, supplied -/// out-of-band by MLS on the receiving side and passed to [`resolve_sender`]. +/// The device key is *not* embedded — it is the leaf's signature key, supplied +/// out-of-band by MLS on the receiving side and paired in [`decode_credential`]. /// /// ```text /// domain : CREDENTIAL_DOMAIN (constant prefix, NUL-terminated) /// version : u8 (1 byte) /// account_pub : [u8; 32] (32 bytes) -/// endorsement : [u8; 64] (64 bytes) — account signature over endorsement_message /// ``` -pub fn encode_credential(account: &Ed25519VerifyingKey, endorsement: &Ed25519Signature) -> Vec { - let mut out = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 1 + 32 + 64); +pub fn encode_credential(account: &Ed25519VerifyingKey) -> Vec { + let mut out = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 1 + 32); out.extend_from_slice(CREDENTIAL_DOMAIN); out.push(CREDENTIAL_VERSION); out.extend_from_slice(account.as_ref()); - out.extend_from_slice(endorsement.as_ref()); out } -/// Decode `credential_content` and verify the Account's endorsement against -/// `device_key` — the key the MLS leaf actually signs with. On success the -/// caller learns both the LocalIdentity (`device_key`) and the Account that -/// vouches for it. -pub fn resolve_sender( +/// Decode `credential_content` into the raw claim, pairing the embedded Account +/// with `device_key` (the key the MLS leaf actually signs with). This does *not* +/// validate the claim — the account is only asserted until checked against an +/// [`AccountService`]. +pub fn decode_credential( credential_content: &[u8], device_key: &Ed25519VerifyingKey, -) -> Result { - const HEADER: usize = 1 + 32 + 64; +) -> Result { + const HEADER: usize = 1 + 32; let rest = credential_content .strip_prefix(CREDENTIAL_DOMAIN) .ok_or(CredentialError::Domain)?; @@ -121,21 +115,11 @@ pub fn resolve_sender( if version != CREDENTIAL_VERSION { return Err(CredentialError::Version(version)); } - let account_bytes: [u8; 32] = rest[1..33].try_into().expect("33 - 1 == 32"); let account = Ed25519VerifyingKey::from_bytes(&account_bytes).map_err(|_| CredentialError::AccountKey)?; - let sig_bytes: [u8; 64] = rest[33..97].try_into().expect("97 - 33 == 64"); - let endorsement = Ed25519Signature::from(sig_bytes); - // Verifying under the account key, over a message that includes the *leaf's* - // device key, is what binds this device to this account. A credential lifted - // from another sender won't verify against the device key MLS hands us here. - account - .verify(&endorsement_message(&account, device_key), &endorsement) - .map_err(|_| CredentialError::SignatureInvalid)?; - - Ok(MessageSender { + Ok(SenderCredential { account: IdentId::new(hex::encode(account.as_ref())), local_identity: IdentId::new(hex::encode(device_key.as_ref())), }) @@ -145,97 +129,38 @@ pub fn resolve_sender( mod tests { use super::*; use crypto::Ed25519SigningKey; - use std::convert::Infallible; - /// Sign with the account key, resolve under the device key: both identities - /// come back, hex-encoded. + /// encode → decode pairs the embedded account with the supplied device key. #[test] - fn resolves_well_formed_credential() { - let account_key = Ed25519SigningKey::generate(); - let account_pub = account_key.verifying_key(); - let device_key = Ed25519SigningKey::generate(); - let device_pub = device_key.verifying_key(); + fn decodes_well_formed_credential() { + let account = Ed25519SigningKey::generate().verifying_key(); + let device = Ed25519SigningKey::generate().verifying_key(); - let endorsement = endorse_local_identity::(&account_pub, &device_pub, |m| { - Ok(account_key.sign(m)) - }) - .unwrap(); - let content = encode_credential(&account_pub, &endorsement); + let content = encode_credential(&account); + let cred = decode_credential(&content, &device).unwrap(); - let sender = resolve_sender(&content, &device_pub).unwrap(); - assert_eq!(sender.account.as_str(), hex::encode(account_pub.as_ref())); - assert_eq!( - sender.local_identity.as_str(), - hex::encode(device_pub.as_ref()) - ); - } - - /// On testnet the account key *is* the device key (one device == one - /// account); resolution then reports the same id for both. - #[test] - fn single_key_account_resolves_to_itself() { - let key = Ed25519SigningKey::generate(); - let pubkey = key.verifying_key(); - - let endorsement = - endorse_local_identity::(&pubkey, &pubkey, |m| Ok(key.sign(m))).unwrap(); - let content = encode_credential(&pubkey, &endorsement); - - let sender = resolve_sender(&content, &pubkey).unwrap(); - assert_eq!(sender.account, sender.local_identity); - } - - /// An endorsement for device A, presented with device B's key (as MLS would - /// hand it over if B forged a leaf), fails: the signature covers A's key. - #[test] - fn rejects_endorsement_for_a_different_device() { - let account_key = Ed25519SigningKey::generate(); - let account_pub = account_key.verifying_key(); - let device_a = Ed25519SigningKey::generate().verifying_key(); - let device_b = Ed25519SigningKey::generate().verifying_key(); - - let endorsement = endorse_local_identity::(&account_pub, &device_a, |m| { - Ok(account_key.sign(m)) - }) - .unwrap(); - let content = encode_credential(&account_pub, &endorsement); - - assert!(matches!( - resolve_sender(&content, &device_b), - Err(CredentialError::SignatureInvalid) - )); + assert_eq!(cred.account.as_str(), hex::encode(account.as_ref())); + assert_eq!(cred.local_identity.as_str(), hex::encode(device.as_ref())); } #[test] fn rejects_missing_domain_short_and_bad_version() { - let account_key = Ed25519SigningKey::generate(); - let account_pub = account_key.verifying_key(); + let account = Ed25519SigningKey::generate().verifying_key(); let device = Ed25519SigningKey::generate().verifying_key(); - let endorsement = - endorse_local_identity::( - &account_pub, - &device, - |m| Ok(account_key.sign(m)), - ) - .unwrap(); - let content = encode_credential(&account_pub, &endorsement); + let content = encode_credential(&account); - // Strip the domain prefix. assert!(matches!( - resolve_sender(&content[CREDENTIAL_DOMAIN.len()..], &device), + decode_credential(&content[CREDENTIAL_DOMAIN.len()..], &device), Err(CredentialError::Domain) )); - // Domain present but body truncated. - let short = &content[..CREDENTIAL_DOMAIN.len() + 4]; assert!(matches!( - resolve_sender(short, &device), + decode_credential(&content[..CREDENTIAL_DOMAIN.len() + 1], &device), Err(CredentialError::Short) )); - // Wrong version byte (first byte after the domain prefix). let mut bad_version = content.clone(); bad_version[CREDENTIAL_DOMAIN.len()] = 99; assert!(matches!( - resolve_sender(&bad_version, &device), + decode_credential(&bad_version, &device), Err(CredentialError::Version(99)) )); } diff --git a/core/account/src/lib.rs b/core/account/src/lib.rs index 773fc83..c636579 100644 --- a/core/account/src/lib.rs +++ b/core/account/src/lib.rs @@ -1,8 +1,8 @@ mod credential; pub use credential::{ - CREDENTIAL_DOMAIN, CREDENTIAL_VERSION, CredentialError, MessageSender, encode_credential, - endorse_local_identity, resolve_sender, + CREDENTIAL_DOMAIN, CREDENTIAL_VERSION, CredentialError, MessageSender, SenderCredential, + decode_credential, encode_credential, }; #[cfg(feature = "dev")] diff --git a/core/conversations/src/account_directory.rs b/core/conversations/src/account_directory.rs index 2a994cb..f242ccb 100644 --- a/core/conversations/src/account_directory.rs +++ b/core/conversations/src/account_directory.rs @@ -108,6 +108,40 @@ pub trait AccountDirectory: Debug { fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error>; } +/// Confirms whether a device key really belongs to an account — the trust step +/// a [`SenderCredential`](logos_account::SenderCredential)'s account claim is +/// checked against before it is reported to the application. +/// +/// Backed by the [`AccountDirectory`]: any directory client is an +/// `AccountService` via the blanket impl below, which fetches the account's +/// verified device set and checks membership. +pub trait AccountService { + type Error: Display + Debug; + + /// True if `device` is a registered LocalIdentity of `account`. + fn is_local_identity_of( + &self, + account: &Ed25519VerifyingKey, + device: &Ed25519VerifyingKey, + ) -> Result; +} + +impl AccountService for D { + type Error = ::Error; + + fn is_local_identity_of( + &self, + account: &Ed25519VerifyingKey, + device: &Ed25519VerifyingKey, + ) -> Result { + // No published bundle → can't confirm the device belongs to the account. + let Some(set) = self.fetch(account)? else { + return Ok(false); + }; + Ok(set.devices.contains(&hex::encode(device.as_ref()))) + } +} + /// Failures decoding or verifying a [`SignedDeviceBundle`]. #[derive(Debug, Error)] pub enum BundleError { @@ -395,6 +429,36 @@ mod tests { assert_eq!(resolved, want); } + /// `AccountService` (blanket impl over the directory): a published device + /// validates for its account; a stranger device and an unknown account do + /// not. + #[test] + fn account_service_checks_device_membership() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let device = Ed25519SigningKey::generate().verifying_key(); + let stranger = Ed25519SigningKey::generate().verifying_key(); + + let payload = encode_bundle_payload(1, std::slice::from_ref(&device)); + let bundle = SignedDeviceBundle { + account_pub: account_pub.clone(), + signature: account_key.sign(&payload), + payload, + }; + let dir = FakeDir(Some(bundle)); + + assert!(dir.is_local_identity_of(&account_pub, &device).unwrap()); + assert!(!dir.is_local_identity_of(&account_pub, &stranger).unwrap()); + + // An account with no published bundle can't confirm anything. + let unknown = Ed25519SigningKey::generate().verifying_key(); + assert!( + !FakeDir(None) + .is_local_identity_of(&unknown, &device) + .unwrap() + ); + } + /// Tampering with any payload byte breaks verification. #[test] fn verify_rejects_tampered_payload() { diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 49ad0d2..78c733a 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -6,7 +6,7 @@ use blake2::{Blake2b, Digest, digest::consts::U6}; use chat_proto::logoschat::encryption::{EncryptedPayload, Plaintext, encrypted_payload}; use chat_proto::logoschat::reliability::ReliablePayload; use crypto::Ed25519VerifyingKey; -use logos_account::MessageSender; +use logos_account::SenderCredential; use openmls::prelude::tls_codec::Deserialize; use openmls::prelude::*; use prost::Message as _; @@ -176,24 +176,25 @@ impl GroupV1Convo { &self.convo_id } - /// Resolve the verified [`MessageSender`] for a processed message. + /// Decode the (unvalidated) [`SenderCredential`] for a processed message. /// /// MLS guarantees the message was signed by the sender leaf's key, which is /// the device (LocalIdentity) key. We pair that authoritative key with the - /// account binding carried in the credential to recover both the Account and - /// the LocalIdentity. Returns `None` for non-member senders or if the - /// credential isn't a well-formed, validly-endorsed account binding. - fn resolve_sender(&self, processed: &ProcessedMessage) -> Option { + /// Account *claim* carried in the credential content. The account claim is + /// not trusted here — the client validates it against an + /// [`AccountService`](logos_account::AccountService). Returns `None` for + /// non-member senders or a malformed credential. + fn sender_credential(&self, processed: &ProcessedMessage) -> Option { let Sender::Member(leaf_index) = processed.sender() else { return None; }; // The leaf's signature key is the device key MLS verified the message - // against — the trustworthy LocalIdentity, not anything self-asserted in - // the credential content. + // against — the trustworthy LocalIdentity. let member = self.mls_group.member_at(*leaf_index)?; let key_bytes: [u8; 32] = member.signature_key.as_slice().try_into().ok()?; let device_key = Ed25519VerifyingKey::from_bytes(&key_bytes).ok()?; - logos_account::resolve_sender(processed.credential().serialized_content(), &device_key).ok() + logos_account::decode_credential(processed.credential().serialized_content(), &device_key) + .ok() } fn send_message( @@ -270,9 +271,9 @@ impl Convo for GroupV1Convo { .process_message(&cx.mls_provider, protocol_message) .map_err(ChatError::generic)?; - // Resolve the sender before consuming `processed`; only surfaced - // alongside application content below. - let sender = self.resolve_sender(&processed); + // Decode the sender credential before consuming `processed`; only + // surfaced alongside application content below. + let credential = self.sender_credential(&processed); let content = match processed.into_content() { ProcessedMessageContent::ApplicationMessage(msg) => { @@ -293,14 +294,14 @@ impl Convo for GroupV1Convo { None } }; - // A commit (content == None) has a sender but no application payload; - // keep the sender paired with content so consumers see it only on - // actual messages. - let sender = content.as_ref().and(sender); + // A commit (content == None) has a credential but no application + // payload; keep the credential paired with content so consumers see it + // only on actual messages. + let credential = content.as_ref().and(credential); Ok(ConvoOutcome { convo_id: self.id().to_string(), content, - sender, + credential, }) } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index aa0ff67..0fb5043 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -21,7 +21,7 @@ use de_mls::protos::de_mls::messages::v1::{ }; use de_mls::session::{Conversation, ConversationConfig, ConversationDeps}; use hashgraph_like_consensus::signing::EthereumConsensusSigner; -use logos_account::MessageSender; +use logos_account::SenderCredential; use prost::Message; use shared_traits::{IdentId, IdentIdRef}; use std::sync::Arc; @@ -464,10 +464,11 @@ impl GroupV2Convo { // de-mls carries only the sender's member-id (the LocalIdentity // display string); it does not yet expose an account-bound // credential the way GroupV1 does. Surface the LocalIdentity and - // treat it as its own Account (the testnet 1:1 case). - // TODO: resolve the Account once de-mls exposes the sender - // credential, mirroring GroupV1Convo::resolve_sender. - sender: Some(MessageSender { + // claim it as its own Account (the testnet 1:1 case). This claim + // won't validate against the account directory, so the client + // reports it unverified. + // TODO: surface a real credential once de-mls exposes the sender. + credential: Some(SenderCredential { account: IdentId::new(cm.sender.clone()), local_identity: IdentId::new(cm.sender.clone()), }), diff --git a/core/conversations/src/conversation/privatev1.rs b/core/conversations/src/conversation/privatev1.rs index 80db85e..2cd9105 100644 --- a/core/conversations/src/conversation/privatev1.rs +++ b/core/conversations/src/conversation/privatev1.rs @@ -273,8 +273,8 @@ impl Convo for PrivateV1Convo { Ok(ConvoOutcome { convo_id: self.id().to_string(), content, - // TODO: surface the peer identity for 1:1 conversations. - sender: None, + // TODO: surface the peer credential for 1:1 conversations. + credential: None, }) } diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 0997afa..fd7dca7 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -1,7 +1,10 @@ use crate::causal_history::{CausalHistoryStore, MissingMessage}; use crate::conversation::{ConversationIdRef, GroupV1Convo, GroupV2Convo, PrivateV1Convo}; use crate::service_context::{ExternalServices, ServiceContext}; -use crate::{DeliveryService, IdentityProvider, RegistrationService, WakeupService}; +use crate::{ + AccountService, DeliveryService, IdentityProvider, MessageSender, RegistrationService, + SenderCredential, WakeupService, +}; use crate::{ conversation::{Convo, GroupConvo}, errors::ChatError, @@ -10,7 +13,7 @@ use crate::{ outcomes::{ConvoOutcome, InboxOutcome, PayloadOutcome}, proto::{EncryptedPayload, EnvelopeV1, Message}, }; -use crypto::{Identity, PublicKey}; +use crypto::{Ed25519VerifyingKey, Identity, PublicKey}; use openmls::group::GroupId; use shared_traits::IdentIdRef; use std::collections::HashMap; @@ -292,6 +295,32 @@ impl<'a, S: ExternalServices + 'static> Core { self.services.causal.take_missing() } + /// Validate a [`SenderCredential`] against the account → device directory. + /// + /// This is the trust step the credential's account *claim* is checked + /// against: if the device key is a registered LocalIdentity of the claimed + /// account, returns the confirmed [`MessageSender`] identifier. `Ok(None)` + /// means the claim could not be confirmed — unknown account, unregistered + /// device, or a non-key identifier (e.g. a de-mls member id that isn't an + /// account key). + pub fn validate_sender( + &self, + cred: &SenderCredential, + ) -> Result, ChatError> { + let (Some(account), Some(device)) = ( + key_from_hex(cred.account.as_str()), + key_from_hex(cred.local_identity.as_str()), + ) else { + return Ok(None); + }; + let valid = self + .services + .registry + .is_local_identity_of(&account, &device) + .map_err(|e| ChatError::Generic(e.to_string()))?; + Ok(valid.then(|| MessageSender::validated(cred.clone()))) + } + /// Encrypt and publish `content` to an existing conversation. pub fn send_content(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ChatError> { if self.cached_convos.contains_key(convo_id) { @@ -464,6 +493,13 @@ impl<'a, S: ExternalServices + 'static> Core { } } +/// Parse a hex-encoded Ed25519 verifying key, as carried in a +/// [`SenderCredential`]'s ids. Returns `None` for non-key identifiers. +fn key_from_hex(s: &str) -> Option { + let bytes: [u8; 32] = hex::decode(s).ok()?.try_into().ok()?; + Ed25519VerifyingKey::from_bytes(&bytes).ok() +} + #[derive(Debug)] enum ConvoTypeOwned { // Pairwise(Box>), diff --git a/core/conversations/src/inbox_v2/identity.rs b/core/conversations/src/inbox_v2/identity.rs index 4dfa854..9632672 100644 --- a/core/conversations/src/inbox_v2/identity.rs +++ b/core/conversations/src/inbox_v2/identity.rs @@ -26,19 +26,15 @@ impl MlsIdentityProvider { /// Build the MLS leaf credential for this identity. /// - /// The credential content binds this device (LocalIdentity) to its Account: - /// the leaf's signature key is the device key, and the content carries the - /// Account key plus the Account's endorsement of that device key. A receiver - /// recovers both via [`logos_account::resolve_sender`]. On testnet the - /// account key *is* the device key, so the endorsement is self-signed. + /// The credential is a plain claim: its content carries the **Account** key, + /// and the leaf's signature key is the **device** (LocalIdentity) key. A + /// receiver decodes both via [`logos_account::decode_credential`] and must + /// validate the account claim against an + /// [`AccountService`](logos_account::AccountService) before trusting it. pub fn get_credential(&self) -> Result { let account = AccountAuthority::account_pub(self).clone(); let device = self.public_key().clone(); - let endorsement = logos_account::endorse_local_identity(&account, &device, |msg| { - AccountAuthority::sign(self, msg) - }) - .map_err(|e| ChatError::Generic(e.to_string()))?; - let content = logos_account::encode_credential(&account, &endorsement); + let content = logos_account::encode_credential(&account); Ok(CredentialWithKey { credential: BasicCredential::new(content).into(), signature_key: device.as_ref().into(), diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index fa8ff09..2583f0c 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -14,8 +14,8 @@ mod types; mod utils; pub use account_directory::{ - AccountAuthority, AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, - DeviceSet, Lamport, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload, + AccountAuthority, AccountDirectory, AccountService, 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}; @@ -23,7 +23,7 @@ pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; pub use core::{ConversationId, Core, Introduction}; pub use errors::ChatError; -pub use logos_account::MessageSender; +pub use logos_account::{MessageSender, SenderCredential}; pub use outcomes::{ Content, ConversationClass, ConvoOutcome, InboxOutcome, NewConversation, PayloadOutcome, }; diff --git a/core/conversations/src/outcomes.rs b/core/conversations/src/outcomes.rs index 1cb6545..c970c4c 100644 --- a/core/conversations/src/outcomes.rs +++ b/core/conversations/src/outcomes.rs @@ -6,7 +6,7 @@ //! initial [`ConvoOutcome`]. //! - [`PayloadOutcome`] — the union of the above, plus `Empty`. -use logos_account::MessageSender; +use logos_account::SenderCredential; use storage::ConversationKind; use crate::conversation::ConversationId; @@ -20,11 +20,13 @@ pub struct Content { pub struct ConvoOutcome { pub convo_id: ConversationId, pub content: Option, - /// The verified sender of `content`: both the Account and the specific - /// LocalIdentity (device) it was sent from. `None` for control messages - /// (e.g. MLS commits) that carry no application content, and for - /// conversation types that don't yet surface a sender. - pub sender: Option, + /// The *unvalidated* sender credential for `content`: the claimed Account + /// and the device (LocalIdentity) it was sent from. The device key is + /// MLS-authenticated, but the account claim must be validated against an + /// [`AccountService`](logos_account::AccountService) before it is trusted. + /// `None` for control messages (e.g. MLS commits) carrying no application + /// content, and for conversation types that don't yet surface a credential. + pub credential: Option, } impl ConvoOutcome { @@ -32,7 +34,7 @@ impl ConvoOutcome { Self { convo_id, content: None, - sender: None, + credential: None, } } } diff --git a/core/integration_tests_core/src/test_client.rs b/core/integration_tests_core/src/test_client.rs index 47f6ab9..1064515 100644 --- a/core/integration_tests_core/src/test_client.rs +++ b/core/integration_tests_core/src/test_client.rs @@ -75,10 +75,16 @@ impl TestClient { content = String::from_utf8_lossy(&data.bytes).to_string(), "COT" ); + // Validate the raw credential against the account + // directory, exactly as the client does. + let sender = convo_outcome + .credential + .as_ref() + .and_then(|c| self.inner.validate_sender(c).ok().flatten()); self.received_messages.push(ReceivedMessage { convo_id: convo_outcome.convo_id.clone(), contents: data.bytes.clone(), - sender: convo_outcome.sender.clone(), + sender, }); } } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 08f7404..36448dc 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -5,7 +5,7 @@ use components::{EphemeralRegistry, ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; use libchat::{ ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, InboxOutcome, - Introduction, PayloadOutcome, RegistrationService, StorageConfig, + Introduction, MessageSender, PayloadOutcome, RegistrationService, StorageConfig, }; use logos_account::TestLogosAccount; use parking_lot::Mutex; @@ -220,7 +220,9 @@ fn worker_loop( let events = { let mut core = core.lock(); match core.handle_payload(&bytes) { - Ok(outcome) => events_from_inbound(outcome), + // Validation of the sender credential reads the account + // directory, so it runs while the core is still locked. + Ok(outcome) => events_from_inbound(&core, outcome), Err(e) => { tracing::warn!("inbound handle_payload failed: {e:?}"); vec![Event::InboundError { @@ -252,23 +254,42 @@ fn worker_loop( /// observation. For an `Inbox` outcome, [`Event::ConversationStarted`] /// precedes the message event. The convo id is wrapped into `Arc` once /// per outcome and shared across the events it produces. -fn events_from_inbound(result: PayloadOutcome) -> Vec { +/// +/// `core` is borrowed so the raw sender credential can be validated against the +/// account directory — turning the claim into a trusted [`MessageSender`]. +fn events_from_inbound(core: &ClientCore, result: PayloadOutcome) -> Vec +where + T: DeliveryService + Send + 'static, + R: RegistrationService + Send + 'static, +{ match result { PayloadOutcome::Empty => Vec::new(), - PayloadOutcome::Convo(co) => convo_events(co), - PayloadOutcome::Inbox(io) => inbox_events(io), + PayloadOutcome::Convo(co) => convo_events(core, co), + PayloadOutcome::Inbox(io) => inbox_events(core, io), } } -fn convo_events(outcome: ConvoOutcome) -> Vec { - let ConvoOutcome { - convo_id, - content, - sender, - } = outcome; - content +/// Validate the outcome's sender credential against the account service, +/// yielding the trusted identifier (or `None` when the claim can't be confirmed). +fn validate(core: &ClientCore, outcome: &ConvoOutcome) -> Option +where + T: DeliveryService + Send + 'static, + R: RegistrationService + Send + 'static, +{ + let cred = outcome.credential.as_ref()?; + core.validate_sender(cred).ok().flatten() +} + +fn convo_events(core: &ClientCore, outcome: ConvoOutcome) -> Vec +where + T: DeliveryService + Send + 'static, + R: RegistrationService + Send + 'static, +{ + let sender = validate(core, &outcome); + outcome + .content .map(|c| Event::MessageReceived { - convo_id: Arc::from(convo_id), + convo_id: Arc::from(outcome.convo_id), content: c.bytes, sender, }) @@ -276,7 +297,11 @@ fn convo_events(outcome: ConvoOutcome) -> Vec { .collect() } -fn inbox_events(outcome: InboxOutcome) -> Vec { +fn inbox_events(core: &ClientCore, outcome: InboxOutcome) -> Vec +where + T: DeliveryService + Send + 'static, + R: RegistrationService + Send + 'static, +{ let InboxOutcome { new_conversation, initial, @@ -287,14 +312,15 @@ fn inbox_events(outcome: InboxOutcome) -> Vec { convo_id: Arc::clone(&id), class: new_conversation.class, }); - if let Some(co) = initial - && let Some(c) = co.content - { - events.push(Event::MessageReceived { - convo_id: Arc::clone(&id), - content: c.bytes, - sender: co.sender, - }); + if let Some(co) = initial { + let sender = validate(core, &co); + if let Some(c) = co.content { + events.push(Event::MessageReceived { + convo_id: Arc::clone(&id), + content: c.bytes, + sender, + }); + } } events }