mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-27 19:49:31 +00:00
feat: sender check with account store (#142)
This commit is contained in:
parent
d02689c764
commit
aec902d796
@ -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
|
||||
}
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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) =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user