mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-28 03:59:27 +00:00
feat: use account service for verification
This commit is contained in:
parent
2e8d9fb30a
commit
d7ce1d58a6
@ -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))
|
||||
));
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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()),
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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>>),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user