receiver recovers both the local identity and the account

This commit is contained in:
kaichaosun 2026-06-17 12:11:48 +08:00
parent e163980715
commit 9eee747182
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
18 changed files with 390 additions and 28 deletions

4
Cargo.lock generated
View File

@ -3250,6 +3250,7 @@ dependencies = [
"double-ratchets",
"hashgraph-like-consensus",
"hex",
"logos-account",
"openmls",
"openmls_libcrux_crypto 0.3.1",
"openmls_memory_storage 0.5.0",
@ -3590,8 +3591,9 @@ name = "logos-account"
version = "0.1.0"
dependencies = [
"crypto",
"libchat",
"hex",
"shared-traits",
"thiserror",
]
[[package]]

View File

@ -9,7 +9,8 @@ dev = []
[dependencies]
# Workspace dependencies (sorted)
crypto = { workspace = true }
libchat = { workspace = true }
shared-traits = { workspace = true }
# External dependencies (sorted)
hex = "0.4.3"
thiserror = "2.0.17"

View File

@ -1,7 +1,5 @@
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
use shared_traits::{IdentId, IdentIdRef};
use libchat::IdentityProvider;
use shared_traits::{IdentId, IdentIdRef, IdentityProvider};
/// A Test Focused LogosAccount using a pre-defined identifier.
/// The test account is not persisted, and uses a single user provided id.

View File

@ -0,0 +1,242 @@
//! Account ↔ LocalIdentity binding carried inside the MLS leaf credential.
//!
//! 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.
//!
//! This module binds the two together *inside the credential itself*, so a
//! receiver resolves both without any network round-trip or trusted directory:
//!
//! - 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.
use crypto::{Ed25519Signature, 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.
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.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MessageSender {
/// The Account the sending device belongs to.
pub account: IdentId,
/// The specific LocalIdentity (device/installation) that sent the message.
pub local_identity: IdentId,
}
/// Failures decoding or verifying an account-bound credential.
#[derive(Debug, Error)]
pub enum CredentialError {
#[error("credential is missing the account-local-identity domain prefix")]
Domain,
#[error("credential shorter than its declared layout")]
Short,
#[error("unsupported credential version {0}")]
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.
///
/// `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`].
///
/// ```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);
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(
credential_content: &[u8],
device_key: &Ed25519VerifyingKey,
) -> Result<MessageSender, CredentialError> {
const HEADER: usize = 1 + 32 + 64;
let rest = credential_content
.strip_prefix(CREDENTIAL_DOMAIN)
.ok_or(CredentialError::Domain)?;
if rest.len() < HEADER {
return Err(CredentialError::Short);
}
let version = rest[0];
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 {
account: IdentId::new(hex::encode(account.as_ref())),
local_identity: IdentId::new(hex::encode(device_key.as_ref())),
})
}
#[cfg(test)]
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.
#[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();
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 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)
));
}
#[test]
fn rejects_missing_domain_short_and_bad_version() {
let account_key = Ed25519SigningKey::generate();
let account_pub = account_key.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);
// Strip the domain prefix.
assert!(matches!(
resolve_sender(&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),
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),
Err(CredentialError::Version(99))
));
}
}

View File

@ -1,3 +1,10 @@
mod credential;
pub use credential::{
CREDENTIAL_DOMAIN, CREDENTIAL_VERSION, CredentialError, MessageSender, encode_credential,
endorse_local_identity, resolve_sender,
};
#[cfg(feature = "dev")]
mod account;

View File

@ -11,6 +11,7 @@ crate-type = ["rlib"]
blake2 = { workspace = true }
chat-sqlite = { workspace = true }
crypto = { workspace = true }
logos-account = { workspace = true }
shared-traits = { workspace = true }
storage = { workspace = true }

View File

@ -5,6 +5,8 @@
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 openmls::prelude::tls_codec::Deserialize;
use openmls::prelude::*;
use prost::Message as _;
@ -40,13 +42,9 @@ impl GroupV1Convo {
// Create a new conversation with the creator as the only participant.
pub fn new<S: ExternalServices>(cx: &mut ServiceContext<S>) -> Result<Self, ChatError> {
let config = Self::mls_create_config(cx);
let mls_group = MlsGroup::new(
&cx.mls_provider,
&cx.mls_identity,
&config,
cx.mls_identity.get_credential(),
)
.unwrap();
let credential = cx.mls_identity.get_credential()?;
let mls_group =
MlsGroup::new(&cx.mls_provider, &cx.mls_identity, &config, credential).unwrap();
let convo_id = hex::encode(mls_group.group_id().as_slice());
Self::subscribe(&mut cx.ds, &convo_id)?;
@ -178,6 +176,26 @@ impl GroupV1Convo {
&self.convo_id
}
/// Resolve the verified [`MessageSender`] 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> {
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.
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()
}
fn send_message<S: ExternalServices>(
&mut self,
content: &[u8],
@ -252,6 +270,10 @@ 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);
let content = match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(msg) => {
let reliable = ReliablePayload::decode(msg.into_bytes().as_slice())?;
@ -271,9 +293,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);
Ok(ConvoOutcome {
convo_id: self.id().to_string(),
content,
sender,
})
}

View File

@ -21,6 +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 prost::Message;
use shared_traits::{IdentId, IdentIdRef};
use std::sync::Arc;
@ -460,6 +461,16 @@ impl GroupV2Convo {
content: Some(Content {
bytes: cm.message.clone(),
}),
// 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 {
account: IdentId::new(cm.sender.clone()),
local_identity: IdentId::new(cm.sender.clone()),
}),
}),
_ => None,
})

View File

@ -273,6 +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,
})
}

View File

@ -205,14 +205,10 @@ impl InboxV2 {
.ciphersuites(vec![CIPHER_SUITE])
.extensions(vec![ExtensionType::ApplicationId])
.build();
let credential = cx.mls_identity.get_credential()?;
let a = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
CIPHER_SUITE,
&cx.mls_provider,
&cx.mls_identity,
cx.mls_identity.get_credential(),
)
.build(CIPHER_SUITE, &cx.mls_provider, &cx.mls_identity, credential)
.expect("Failed to build KeyPackage");
Ok(a.key_package().clone())

View File

@ -9,6 +9,7 @@ use openmls_traits::{
use shared_traits::IdentIdRef;
use crate::AccountAuthority;
use crate::ChatError;
use crate::IdentityProvider;
/// A Wrapper for an IdentityProvider which provides MLS specific functionality
@ -23,11 +24,25 @@ impl<T: IdentityProvider> MlsIdentityProvider<T> {
Self(inner)
}
pub fn get_credential(&self) -> CredentialWithKey {
CredentialWithKey {
credential: BasicCredential::new(self.id().as_str().as_bytes().to_vec()).into(),
signature_key: self.public_key().as_ref().into(),
}
/// 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.
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);
Ok(CredentialWithKey {
credential: BasicCredential::new(content).into(),
signature_key: device.as_ref().into(),
})
}
}

View File

@ -23,6 +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 outcomes::{
Content, ConversationClass, ConvoOutcome, InboxOutcome, NewConversation, PayloadOutcome,
};

View File

@ -6,6 +6,7 @@
//! initial [`ConvoOutcome`].
//! - [`PayloadOutcome`] — the union of the above, plus `Empty`.
use logos_account::MessageSender;
use storage::ConversationKind;
use crate::conversation::ConversationId;
@ -19,6 +20,11 @@ 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>,
}
impl ConvoOutcome {
@ -26,6 +32,7 @@ impl ConvoOutcome {
Self {
convo_id,
content: None,
sender: None,
}
}
}

View File

@ -1,4 +1,4 @@
use libchat::{ConversationId, Core, IdentityProvider, PayloadOutcome};
use libchat::{ConversationId, Core, IdentityProvider, MessageSender, PayloadOutcome};
use logos_account::TestLogosAccount;
use shared_traits::IdentId;
use std::collections::HashMap;
@ -34,6 +34,8 @@ type ClientType = Core<(
pub struct ReceivedMessage<T> {
pub convo_id: ConversationId,
pub contents: T,
/// The verified sender (Account + LocalIdentity) surfaced with the message.
pub sender: Option<MessageSender>,
}
pub struct TestClient {
@ -76,6 +78,7 @@ impl TestClient {
self.received_messages.push(ReceivedMessage {
convo_id: convo_outcome.convo_id.clone(),
contents: data.bytes.clone(),
sender: convo_outcome.sender.clone(),
});
}
}
@ -102,6 +105,15 @@ impl TestClient {
false
}
/// The verified sender recorded for the (first) message matching
/// `convo_id`/`content`, if any was received.
pub fn sender_of(&self, convo_id: &str, content: &[u8]) -> Option<&MessageSender> {
self.received_messages
.iter()
.find(|m| m.convo_id == convo_id && m.contents == content)
.and_then(|m| m.sender.as_ref())
}
pub fn convo_count(&self) -> usize {
self.list_conversations().map_or(0, |v| v.len())
}

View File

@ -57,4 +57,30 @@ fn create_group() {
assert!(!harness.pax().check(&convo_id, M_R1));
assert!(!harness.pax().check(&convo_id, M_P1));
// Every delivered message carries a verified sender: both the Account and
// the LocalIdentity it was sent from. On testnet each identity is its own
// single-device account, so the two resolve to the same key.
let raya_sender = harness
.saro()
.sender_of(&convo_id, M_R1)
.expect("Saro should see who sent Raya's message")
.clone();
assert_eq!(
raya_sender.account, raya_sender.local_identity,
"single-key testnet account resolves Account == LocalIdentity"
);
let pax_sender = harness
.saro()
.sender_of(&convo_id, M_P1)
.expect("Saro should see who sent Pax's message")
.clone();
// Distinct identities resolve to distinct senders — the basis for telling
// group members apart and for collapsing an account's devices to one Account.
assert_ne!(
raya_sender.account, pax_sender.account,
"Raya and Pax must resolve to different accounts"
);
}

View File

@ -261,11 +261,16 @@ fn events_from_inbound(result: PayloadOutcome) -> Vec<Event> {
}
fn convo_events(outcome: ConvoOutcome) -> Vec<Event> {
let ConvoOutcome { convo_id, content } = outcome;
let ConvoOutcome {
convo_id,
content,
sender,
} = outcome;
content
.map(|c| Event::MessageReceived {
convo_id: Arc::from(convo_id),
content: c.bytes,
sender,
})
.into_iter()
.collect()
@ -282,10 +287,13 @@ fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
convo_id: Arc::clone(&id),
class: new_conversation.class,
});
if let Some(c) = initial.and_then(|co| co.content) {
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,
});
}
events

View File

@ -8,7 +8,7 @@
use std::sync::Arc;
use libchat::ConversationClass;
use libchat::{ConversationClass, MessageSender};
/// A discrete chat event.
#[non_exhaustive]
@ -23,6 +23,10 @@ pub enum Event {
MessageReceived {
convo_id: Arc<str>,
content: Vec<u8>,
/// The verified sender — both the Account and the LocalIdentity
/// (device) it was sent from. `None` when the conversation type does
/// not yet surface a sender.
sender: Option<MessageSender>,
},
InboundError {
message: String,

View File

@ -37,7 +37,9 @@ fn saro_raya_message_exchange() {
other => Err(other),
});
expect_event(&raya_events, "MessageReceived", |e| match e {
Event::MessageReceived { convo_id, content } => {
Event::MessageReceived {
convo_id, content, ..
} => {
assert_eq!(convo_id, raya_convo_id);
assert_eq!(content.as_slice(), b"hello raya");
Ok(())