feat: use account service for verification

This commit is contained in:
kaichaosun 2026-06-18 10:57:19 +08:00
parent 2e8d9fb30a
commit d7ce1d58a6
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
12 changed files with 280 additions and 223 deletions

View File

@ -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<u8> {
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<E>(
account: &Ed25519VerifyingKey,
device: &Ed25519VerifyingKey,
sign: impl FnOnce(&[u8]) -> Result<Ed25519Signature, E>,
) -> Result<Ed25519Signature, E> {
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<u8> {
let mut out = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 1 + 32 + 64);
pub fn encode_credential(account: &Ed25519VerifyingKey) -> Vec<u8> {
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<MessageSender, CredentialError> {
const HEADER: usize = 1 + 32 + 64;
) -> Result<SenderCredential, CredentialError> {
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::<Infallible>(&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::<Infallible>(&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::<Infallible>(&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::<Infallible>(
&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))
));
}

View File

@ -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")]

View File

@ -108,6 +108,40 @@ pub trait AccountDirectory: Debug {
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, 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<bool, Self::Error>;
}
impl<D: AccountDirectory> AccountService for D {
type Error = <D as AccountDirectory>::Error;
fn is_local_identity_of(
&self,
account: &Ed25519VerifyingKey,
device: &Ed25519VerifyingKey,
) -> Result<bool, Self::Error> {
// 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() {

View File

@ -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<MessageSender> {
/// 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<SenderCredential> {
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<S: ExternalServices>(
@ -270,9 +271,9 @@ impl<S: ExternalServices> Convo<S> 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<S: ExternalServices> Convo<S> 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,
})
}

View File

@ -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()),
}),

View File

@ -273,8 +273,8 @@ impl<S: ExternalServices> Convo<S> 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,
})
}

View File

@ -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<S> {
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<Option<MessageSender>, 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<S> {
}
}
/// 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<Ed25519VerifyingKey> {
let bytes: [u8; 32] = hex::decode(s).ok()?.try_into().ok()?;
Ed25519VerifyingKey::from_bytes(&bytes).ok()
}
#[derive(Debug)]
enum ConvoTypeOwned<S: ExternalServices> {
// Pairwise(Box<dyn BaseConvo<S>>),

View File

@ -26,19 +26,15 @@ impl<T: IdentityProvider> MlsIdentityProvider<T> {
/// 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<CredentialWithKey, ChatError> {
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(),

View File

@ -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,
};

View File

@ -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<Content>,
/// 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<MessageSender>,
/// 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<SenderCredential>,
}
impl ConvoOutcome {
@ -32,7 +34,7 @@ impl ConvoOutcome {
Self {
convo_id,
content: None,
sender: None,
credential: None,
}
}
}

View File

@ -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,
});
}
}

View File

@ -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<T, R>(
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<T, R>(
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
/// per outcome and shared across the events it produces.
fn events_from_inbound(result: PayloadOutcome) -> Vec<Event> {
///
/// `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<T, R>(core: &ClientCore<T, R>, result: PayloadOutcome) -> Vec<Event>
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<Event> {
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<T, R>(core: &ClientCore<T, R>, outcome: &ConvoOutcome) -> Option<MessageSender>
where
T: DeliveryService + Send + 'static,
R: RegistrationService + Send + 'static,
{
let cred = outcome.credential.as_ref()?;
core.validate_sender(cred).ok().flatten()
}
fn convo_events<T, R>(core: &ClientCore<T, R>, outcome: ConvoOutcome) -> Vec<Event>
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<Event> {
.collect()
}
fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
fn inbox_events<T, R>(core: &ClientCore<T, R>, outcome: InboxOutcome) -> Vec<Event>
where
T: DeliveryService + Send + 'static,
R: RegistrationService + Send + 'static,
{
let InboxOutcome {
new_conversation,
initial,
@ -287,14 +312,15 @@ fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
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
}