feat: sender check with account store (#142)

This commit is contained in:
kaichao 2026-06-23 13:40:19 +08:00 committed by GitHub
parent d02689c764
commit aec902d796
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 258 additions and 29 deletions

View File

@ -153,6 +153,14 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
&self.services.store
}
/// The account → device directory (our account store). Used to verify that a
/// received message's claimed account actually endorses the sending device
/// before the message is surfaced. Exposed as `RegistrationService`, whose
/// `AccountDirectory` supertrait provides `fetch`.
pub fn account_directory(&self) -> &S::RS {
&self.services.registry
}
pub fn identity(&self) -> &Identity {
&self.services.identity
}

View File

@ -3,9 +3,11 @@ use std::thread::{self, JoinHandle};
use components::{EphemeralRegistry, ThreadedWakeupService, WakeupEvent};
use crossbeam_channel::{Receiver, Sender, select};
use crypto::Ed25519VerifyingKey;
use libchat::{
ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, IdentId,
IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService, StorageConfig,
AccountDirectory, ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService,
IdentId, IdentIdRef, InboxOutcome, Introduction, PayloadOutcome, RegistrationService,
StorageConfig,
};
use parking_lot::Mutex;
@ -268,7 +270,7 @@ fn worker_loop<T, R>(
let events = {
let mut core = core.lock();
match core.handle_payload(&bytes) {
Ok(outcome) => events_from_inbound(outcome),
Ok(outcome) => events_from_inbound(outcome, core.account_directory()),
Err(e) => {
tracing::warn!("inbound handle_payload failed: {e:?}");
vec![Event::InboundError {
@ -300,38 +302,78 @@ 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> {
fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
match result {
PayloadOutcome::Empty => Vec::new(),
PayloadOutcome::Convo(co) => convo_events(co),
PayloadOutcome::Inbox(io) => inbox_events(io),
PayloadOutcome::Convo(co) => convo_events(co, directory),
PayloadOutcome::Inbox(io) => inbox_events(io, directory),
}
}
fn decode_credential(encoded: Vec<u8>) {
if let Ok(data) = hex::decode(encoded)
&& let Ok(cred) = DelegateCredential::try_from(data)
{
tracing::debug!(?cred, "decoded sender credential");
// TODO: Integration Point
/// Interpret a hex account address as an Ed25519 account verifying key.
fn account_key_from_hex(addr: &str) -> Option<Ed25519VerifyingKey> {
let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?;
Ed25519VerifyingKey::from_bytes(&bytes).ok()
}
/// Whether to surface a received message, given its sender credential checked
/// against the account → device directory (our account store).
///
/// The credential binds a delegate device key to an optional account address.
/// When it claims an account, that account's published device set must include
/// this device — otherwise the account→device mapping is wrong or unconfirmable
/// and the message is dropped (`false`). A credential that claims no account (or
/// no credential at all) asserts no mapping, so it is delivered (`true`).
fn should_deliver(directory: &impl AccountDirectory, encoded: &[u8]) -> bool {
// No credential (e.g. the PrivateV1 placeholder) asserts no account mapping.
if encoded.is_empty() {
return true;
}
let Ok(data) = hex::decode(encoded) else {
tracing::warn!("sender credential is not valid hex; dropping message");
return false;
};
let cred = match DelegateCredential::try_from(data) {
Ok(cred) => cred,
Err(_) => {
tracing::warn!("malformed sender credential; dropping message");
return false;
}
};
let device = hex::encode(cred.delegate_id().as_ref());
// An unassociated delegate asserts no account → device mapping.
let Some(account_addr) = cred.account_addr() else {
return true;
};
let Some(account_key) = account_key_from_hex(account_addr) else {
tracing::warn!(
account_addr,
"sender account address is not a verifying key; dropping message"
);
return false;
};
match directory.fetch(&account_key) {
Ok(Some(set)) if set.devices.iter().any(|d| d == &device) => true,
_ => {
tracing::warn!(account_addr, %device, "account → device mapping is wrong or unconfirmable; dropping message");
false
}
}
}
fn convo_events(outcome: ConvoOutcome) -> Vec<Event> {
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
let ConvoOutcome { convo_id, content } = outcome;
content
.map(|c| {
decode_credential(c.encoded_credential);
Event::MessageReceived {
convo_id: Arc::from(convo_id),
content: c.bytes,
}
.filter(|c| should_deliver(directory, &c.encoded_credential))
.map(|c| Event::MessageReceived {
convo_id: Arc::from(convo_id),
content: c.bytes,
})
.into_iter()
.collect()
}
fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
let InboxOutcome {
new_conversation,
initial,
@ -342,8 +384,9 @@ 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) {
decode_credential(c.encoded_credential);
if let Some(c) = initial.and_then(|co| co.content)
&& should_deliver(directory, &c.encoded_credential)
{
events.push(Event::MessageReceived {
convo_id: Arc::clone(&id),
content: c.bytes,
@ -351,3 +394,150 @@ fn inbox_events(outcome: InboxOutcome) -> Vec<Event> {
}
events
}
#[cfg(test)]
mod sender_check_tests {
use std::collections::HashMap;
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
use libchat::{DeviceSet, SignedDeviceBundle};
use super::should_deliver;
use crate::delegate::DelegateCredential;
/// In-test account → device directory. Holds device id sets keyed by the hex
/// account key, and can be made to fail to simulate a directory outage.
#[derive(Debug, Default)]
struct FakeDir {
bundles: HashMap<String, Vec<String>>,
fail: bool,
}
impl FakeDir {
/// Publish `devices` (verifying keys) as `account`'s device set.
fn with_devices(account: &Ed25519VerifyingKey, devices: &[&Ed25519VerifyingKey]) -> Self {
let mut bundles = HashMap::new();
bundles.insert(
hex::encode(account.as_ref()),
devices.iter().map(|d| hex::encode(d.as_ref())).collect(),
);
Self {
bundles,
fail: false,
}
}
}
impl libchat::AccountDirectory for FakeDir {
type Error = &'static str;
fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> {
Ok(())
}
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error> {
if self.fail {
return Err("directory unavailable");
}
Ok(self
.bundles
.get(&hex::encode(account.as_ref()))
.map(|devices| DeviceSet {
lamport: 1,
devices: devices.clone(),
}))
}
}
fn key() -> Ed25519VerifyingKey {
Ed25519SigningKey::generate().verifying_key()
}
/// Encode a credential exactly as it travels on the wire: the hex of the
/// serialized TLV, matching the MLS leaf credential's content bytes.
fn encoded(cred: DelegateCredential) -> Vec<u8> {
hex::encode(cred.serialize()).into_bytes()
}
/// The account published a device set that includes the sending device — the
/// claim checks out, so the message is delivered.
#[test]
fn verified_sender_is_delivered() {
let account = key();
let device = key();
let dir = FakeDir::with_devices(&account, &[&device]);
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert!(should_deliver(&dir, &encoded(cred)));
}
/// The account published a device set that does NOT include the sending
/// device — a spoofed account claim, so the message is dropped.
#[test]
fn contradicted_claim_is_dropped() {
let account = key();
let endorsed = key();
let spoofer = key();
let dir = FakeDir::with_devices(&account, &[&endorsed]);
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
assert!(!should_deliver(&dir, &encoded(cred)));
}
/// A delegate that claims no account makes no mapping to contradict.
#[test]
fn unassociated_sender_is_delivered() {
let dir = FakeDir::default();
let cred = DelegateCredential::unassociated(&key());
assert!(should_deliver(&dir, &encoded(cred)));
}
/// The claimed account has never published a device set — the mapping is
/// missing, so the message is dropped.
#[test]
fn unpublished_account_is_dropped() {
let account = key();
let device = key();
let dir = FakeDir::default(); // nothing published
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert!(!should_deliver(&dir, &encoded(cred)));
}
/// A directory outage leaves the mapping unconfirmed, so the message is
/// dropped rather than delivered on an unverified claim.
#[test]
fn directory_error_is_dropped() {
let account = key();
let device = key();
let dir = FakeDir {
fail: true,
..Default::default()
};
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert!(!should_deliver(&dir, &encoded(cred)));
}
/// No credential at all (e.g. the PrivateV1 placeholder) asserts no account
/// mapping and is delivered.
#[test]
fn empty_credential_is_delivered() {
let dir = FakeDir::default();
assert!(should_deliver(&dir, b""));
}
/// Bytes that aren't a well-formed credential leave the sender's mapping
/// undeterminable, so the message is dropped.
#[test]
fn malformed_credential_is_dropped() {
let dir = FakeDir::default();
assert!(!should_deliver(&dir, b"not hex"));
assert!(!should_deliver(&dir, hex::encode([0u8; 4]).as_bytes()));
}
/// An account address that isn't a verifying key can't be looked up, so the
/// claim is unconfirmable and the message is dropped.
#[test]
fn non_key_account_address_is_dropped() {
let dir = FakeDir::default();
let cred = DelegateCredential::associated(&key(), "user@example.com");
assert!(!should_deliver(&dir, &encoded(cred)));
}
}

View File

@ -89,6 +89,17 @@ impl DelegateCredential {
}
}
/// The delegate (device / LocalIdentity) verifying key this credential names.
pub fn delegate_id(&self) -> &Ed25519VerifyingKey {
&self.delegate_id
}
/// The account this delegate claims to act for, if it is associated. The
/// claim is unverified — confirm it against the account directory.
pub fn account_addr(&self) -> Option<&str> {
self.account_addr.as_deref()
}
pub fn serialize(self) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&[0x23, 0x23]);

View File

@ -2,13 +2,31 @@ use std::time::Duration;
use components::EphemeralRegistry;
use crossbeam_channel::{Receiver, Sender};
use libchat::IdentityProvider;
use crypto::Ed25519VerifyingKey;
use libchat::{AccountDirectory, IdentityProvider, SignedDeviceBundle, encode_bundle_payload};
use logos_account::TestLogosAccount;
use logos_chat::{
AddressedEnvelope, ChatClient, DelegateSigner, DeliveryService, Event, InProcessDelivery,
MessageBus, StorageConfig, Transport,
};
/// Publish a signed device bundle endorsing `device` as a device of `account`,
/// so a receiver can verify the sender's account → device mapping.
fn publish_device_bundle(
reg: &mut EphemeralRegistry,
account: &TestLogosAccount,
device: &Ed25519VerifyingKey,
) {
let payload = encode_bundle_payload(0, std::slice::from_ref(device));
let signature = account.sign(&payload);
let bundle = SignedDeviceBundle {
account_pub: account.public_key().clone(),
payload,
signature,
};
reg.publish(&bundle).unwrap();
}
/// Block until the next event arrives and matches; panic on timeout/mismatch.
fn expect_event<F, T>(events: &Receiver<Event>, label: &str, mut f: F) -> T
where
@ -26,18 +44,20 @@ fn direct_v1_integration() {
let saro_delivery = InProcessDelivery::new(bus.clone());
let raya_delivery = InProcessDelivery::new(bus);
let reg_service = EphemeralRegistry::new();
let mut reg_service = EphemeralRegistry::new();
// Create Accounts, Deletage and Associate the two.
// Create accounts and delegates, associate each delegate with its account
// address, and publish a device bundle so the receiver can verify the
// account → device mapping carried in the sender's credential.
let saro_account = TestLogosAccount::new("Saro");
let mut saro_delegate = DelegateSigner::random();
// TODO: Submit Delegate to Account for auth.
saro_delegate.associate(saro_account.id().to_string());
saro_delegate.associate(hex::encode(saro_account.public_key().as_ref()));
publish_device_bundle(&mut reg_service, &saro_account, saro_delegate.public_key());
let raya_account = TestLogosAccount::new("Raya");
let mut raya_delegate = DelegateSigner::random();
// TODO: Submit Delegate to Account for auth.
raya_delegate.associate(raya_account.id().to_string());
raya_delegate.associate(hex::encode(raya_account.public_key().as_ref()));
publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key());
let raya_delegate_id = raya_delegate.id().clone();
let (mut saro, _saro_events) =