feat(p2p): add MeshNode integrating all production modules
New mesh_node.rs providing a production-ready node: - MeshNodeBuilder for fluent configuration - MeshConfig integration for all settings - MeshMetrics tracking for all operations - Rate limiting on incoming messages - Backpressure controller - Graceful shutdown via ShutdownCoordinator - Optional FappRouter based on capabilities - MeshRouter for envelope routing - TransportManager for multi-transport support Key APIs: - MeshNodeBuilder::new().fapp_relay().build() - node.process_incoming() with rate limiting + metrics - node.gc() for store/routing table cleanup - node.shutdown() for graceful termination 222 tests passing (203 lib + 3 fapp_flow + 16 multi_node)
This commit is contained in:
532
crates/meshservice/src/anti_abuse.rs
Normal file
532
crates/meshservice/src/anti_abuse.rs
Normal file
@@ -0,0 +1,532 @@
|
||||
//! Anti-abuse mechanisms for preventing slot blocking and spam.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Rate limiting configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RateLimits {
|
||||
/// Max reservations per sender per hour.
|
||||
pub max_reservations_per_hour: u8,
|
||||
/// Max pending (unconfirmed) reservations per sender.
|
||||
pub max_pending_reservations: u8,
|
||||
/// Min time between reservations (seconds).
|
||||
pub reservation_cooldown_secs: u32,
|
||||
/// Max queries per sender per minute.
|
||||
pub max_queries_per_minute: u8,
|
||||
}
|
||||
|
||||
impl Default for RateLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_reservations_per_hour: 3,
|
||||
max_pending_reservations: 2,
|
||||
reservation_cooldown_secs: 300,
|
||||
max_queries_per_minute: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks sender activity for rate limiting.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RateLimiter {
|
||||
limits: RateLimits,
|
||||
/// sender_address -> activity
|
||||
activity: HashMap<[u8; 16], SenderActivity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct SenderActivity {
|
||||
/// Timestamps of reservations in last hour.
|
||||
reservation_times: Vec<u64>,
|
||||
/// Count of pending reservations.
|
||||
pending_count: u8,
|
||||
/// Timestamp of last reservation.
|
||||
last_reservation: u64,
|
||||
/// Query timestamps in last minute.
|
||||
query_times: Vec<u64>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// Create with default limits.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create with custom limits.
|
||||
pub fn with_limits(limits: RateLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
activity: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a reservation is allowed.
|
||||
pub fn check_reservation(&mut self, sender: &[u8; 16]) -> RateLimitResult {
|
||||
let now = now();
|
||||
let activity = self.activity.entry(*sender).or_default();
|
||||
|
||||
// Clean old entries
|
||||
activity.reservation_times.retain(|&t| now - t < 3600);
|
||||
|
||||
// Check cooldown
|
||||
if now - activity.last_reservation < u64::from(self.limits.reservation_cooldown_secs) {
|
||||
return RateLimitResult::Cooldown {
|
||||
wait_secs: self.limits.reservation_cooldown_secs - (now - activity.last_reservation) as u32,
|
||||
};
|
||||
}
|
||||
|
||||
// Check hourly limit
|
||||
if activity.reservation_times.len() >= self.limits.max_reservations_per_hour as usize {
|
||||
return RateLimitResult::HourlyLimitReached;
|
||||
}
|
||||
|
||||
// Check pending limit
|
||||
if activity.pending_count >= self.limits.max_pending_reservations {
|
||||
return RateLimitResult::TooManyPending;
|
||||
}
|
||||
|
||||
RateLimitResult::Allowed
|
||||
}
|
||||
|
||||
/// Record a reservation attempt.
|
||||
pub fn record_reservation(&mut self, sender: &[u8; 16]) {
|
||||
let now = now();
|
||||
let activity = self.activity.entry(*sender).or_default();
|
||||
activity.reservation_times.push(now);
|
||||
activity.last_reservation = now;
|
||||
activity.pending_count = activity.pending_count.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Record reservation confirmed/completed (reduce pending).
|
||||
pub fn record_reservation_resolved(&mut self, sender: &[u8; 16]) {
|
||||
if let Some(activity) = self.activity.get_mut(sender) {
|
||||
activity.pending_count = activity.pending_count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a query is allowed.
|
||||
pub fn check_query(&mut self, sender: &[u8; 16]) -> RateLimitResult {
|
||||
let now = now();
|
||||
let activity = self.activity.entry(*sender).or_default();
|
||||
|
||||
// Clean old entries
|
||||
activity.query_times.retain(|&t| now - t < 60);
|
||||
|
||||
if activity.query_times.len() >= self.limits.max_queries_per_minute as usize {
|
||||
return RateLimitResult::QueryLimitReached;
|
||||
}
|
||||
|
||||
RateLimitResult::Allowed
|
||||
}
|
||||
|
||||
/// Record a query.
|
||||
pub fn record_query(&mut self, sender: &[u8; 16]) {
|
||||
let now = now();
|
||||
let activity = self.activity.entry(*sender).or_default();
|
||||
activity.query_times.push(now);
|
||||
}
|
||||
|
||||
/// Prune old activity data.
|
||||
pub fn prune(&mut self) {
|
||||
let now = now();
|
||||
self.activity.retain(|_, a| {
|
||||
a.reservation_times.retain(|&t| now - t < 3600);
|
||||
a.query_times.retain(|&t| now - t < 60);
|
||||
!a.reservation_times.is_empty() || !a.query_times.is_empty() || a.pending_count > 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of rate limit check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RateLimitResult {
|
||||
/// Request allowed.
|
||||
Allowed,
|
||||
/// Must wait before next reservation.
|
||||
Cooldown { wait_secs: u32 },
|
||||
/// Hourly reservation limit reached.
|
||||
HourlyLimitReached,
|
||||
/// Too many pending reservations.
|
||||
TooManyPending,
|
||||
/// Query rate limit reached.
|
||||
QueryLimitReached,
|
||||
}
|
||||
|
||||
impl RateLimitResult {
|
||||
pub fn is_allowed(&self) -> bool {
|
||||
matches!(self, RateLimitResult::Allowed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Proof-of-work for reservation requests.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProofOfWork {
|
||||
/// Nonce that produces valid hash.
|
||||
pub nonce: u64,
|
||||
/// Required difficulty (leading zero bits).
|
||||
pub difficulty: u8,
|
||||
}
|
||||
|
||||
impl ProofOfWork {
|
||||
/// Default difficulty (20 bits ≈ 1-2 seconds on modern CPU).
|
||||
pub const DEFAULT_DIFFICULTY: u8 = 20;
|
||||
|
||||
/// Generate proof-of-work for a reservation.
|
||||
pub fn generate(reservation_id: &[u8; 16], difficulty: u8) -> Self {
|
||||
let mut nonce = 0u64;
|
||||
loop {
|
||||
if Self::check_hash(reservation_id, nonce, difficulty) {
|
||||
return Self { nonce, difficulty };
|
||||
}
|
||||
nonce = nonce.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify proof-of-work.
|
||||
pub fn verify(&self, reservation_id: &[u8; 16]) -> bool {
|
||||
Self::check_hash(reservation_id, self.nonce, self.difficulty)
|
||||
}
|
||||
|
||||
fn check_hash(reservation_id: &[u8; 16], nonce: u64, difficulty: u8) -> bool {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(reservation_id);
|
||||
hasher.update(&nonce.to_le_bytes());
|
||||
let hash = hasher.finalize();
|
||||
leading_zero_bits(&hash) >= difficulty
|
||||
}
|
||||
}
|
||||
|
||||
/// Count leading zero bits in a byte slice.
|
||||
fn leading_zero_bits(data: &[u8]) -> u8 {
|
||||
let mut count = 0u8;
|
||||
for byte in data {
|
||||
if *byte == 0 {
|
||||
count += 8;
|
||||
} else {
|
||||
count += byte.leading_zeros() as u8;
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Sender reputation tracking.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SenderReputation {
|
||||
pub address: [u8; 16],
|
||||
pub reservations_made: u32,
|
||||
pub reservations_honored: u32,
|
||||
pub reservations_cancelled: u32,
|
||||
pub no_shows: u32,
|
||||
pub last_no_show: Option<u64>,
|
||||
}
|
||||
|
||||
impl SenderReputation {
|
||||
/// Create for a new sender.
|
||||
pub fn new(address: [u8; 16]) -> Self {
|
||||
Self {
|
||||
address,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate honor rate (0.0 to 1.0).
|
||||
pub fn honor_rate(&self) -> f32 {
|
||||
if self.reservations_made == 0 {
|
||||
return 0.5; // Neutral for new users
|
||||
}
|
||||
(self.reservations_honored as f32) / (self.reservations_made as f32)
|
||||
}
|
||||
|
||||
/// Check if sender should be blocked.
|
||||
pub fn is_blocked(&self) -> bool {
|
||||
self.no_shows >= 3 || (self.reservations_made >= 5 && self.honor_rate() < 0.5)
|
||||
}
|
||||
|
||||
/// Record a completed reservation.
|
||||
pub fn record_honored(&mut self) {
|
||||
self.reservations_made += 1;
|
||||
self.reservations_honored += 1;
|
||||
}
|
||||
|
||||
/// Record a cancelled reservation (with notice).
|
||||
pub fn record_cancelled(&mut self) {
|
||||
self.reservations_made += 1;
|
||||
self.reservations_cancelled += 1;
|
||||
}
|
||||
|
||||
/// Record a no-show.
|
||||
pub fn record_no_show(&mut self) {
|
||||
self.reservations_made += 1;
|
||||
self.no_shows += 1;
|
||||
self.last_no_show = Some(now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reputation store.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ReputationStore {
|
||||
reputations: HashMap<[u8; 16], SenderReputation>,
|
||||
}
|
||||
|
||||
impl ReputationStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get or create reputation for a sender.
|
||||
pub fn get_or_create(&mut self, address: [u8; 16]) -> &mut SenderReputation {
|
||||
self.reputations
|
||||
.entry(address)
|
||||
.or_insert_with(|| SenderReputation::new(address))
|
||||
}
|
||||
|
||||
/// Get reputation (read-only).
|
||||
pub fn get(&self, address: &[u8; 16]) -> Option<&SenderReputation> {
|
||||
self.reputations.get(address)
|
||||
}
|
||||
|
||||
/// Check if sender is blocked.
|
||||
pub fn is_blocked(&self, address: &[u8; 16]) -> bool {
|
||||
self.reputations
|
||||
.get(address)
|
||||
.map(|r| r.is_blocked())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get honor rate (0.5 for unknown).
|
||||
pub fn honor_rate(&self, address: &[u8; 16]) -> f32 {
|
||||
self.reputations
|
||||
.get(address)
|
||||
.map(|r| r.honor_rate())
|
||||
.unwrap_or(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocklist entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlocklistEntry {
|
||||
pub blocked_address: [u8; 16],
|
||||
pub reason: BlockReason,
|
||||
pub reported_by: [u8; 16],
|
||||
pub signature: Vec<u8>,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Reason for blocking.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum BlockReason {
|
||||
NoShow = 1,
|
||||
Spam = 2,
|
||||
Harassment = 3,
|
||||
FakeIdentity = 4,
|
||||
}
|
||||
|
||||
/// Therapist-defined reservation policy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TherapistPolicy {
|
||||
/// Max pending reservations from new senders.
|
||||
pub max_pending_new: u8,
|
||||
/// Max pending from established senders.
|
||||
pub max_pending_established: u8,
|
||||
/// Require this verification level for reservations.
|
||||
pub min_verification_level: u8,
|
||||
/// Auto-reject senders with honor rate below this.
|
||||
pub min_honor_rate: f32,
|
||||
/// Require proof-of-work.
|
||||
pub require_pow: bool,
|
||||
/// PoW difficulty (if required).
|
||||
pub pow_difficulty: u8,
|
||||
}
|
||||
|
||||
impl Default for TherapistPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_pending_new: 1,
|
||||
max_pending_established: 3,
|
||||
min_verification_level: 0,
|
||||
min_honor_rate: 0.5,
|
||||
require_pow: true,
|
||||
pow_difficulty: ProofOfWork::DEFAULT_DIFFICULTY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TherapistPolicy {
|
||||
/// Check if a reservation request meets policy.
|
||||
pub fn check(
|
||||
&self,
|
||||
sender_reputation: &SenderReputation,
|
||||
sender_verification_level: u8,
|
||||
pow: Option<&ProofOfWork>,
|
||||
reservation_id: &[u8; 16],
|
||||
) -> PolicyResult {
|
||||
// Check verification level
|
||||
if sender_verification_level < self.min_verification_level {
|
||||
return PolicyResult::InsufficientVerification;
|
||||
}
|
||||
|
||||
// Check honor rate
|
||||
if sender_reputation.honor_rate() < self.min_honor_rate {
|
||||
return PolicyResult::LowReputation;
|
||||
}
|
||||
|
||||
// Check blocked
|
||||
if sender_reputation.is_blocked() {
|
||||
return PolicyResult::Blocked;
|
||||
}
|
||||
|
||||
// Check proof-of-work
|
||||
if self.require_pow {
|
||||
match pow {
|
||||
Some(p) if p.difficulty >= self.pow_difficulty && p.verify(reservation_id) => {}
|
||||
Some(_) => return PolicyResult::InvalidPoW,
|
||||
None => return PolicyResult::MissingPoW,
|
||||
}
|
||||
}
|
||||
|
||||
PolicyResult::Allowed
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of policy check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyResult {
|
||||
Allowed,
|
||||
InsufficientVerification,
|
||||
LowReputation,
|
||||
Blocked,
|
||||
MissingPoW,
|
||||
InvalidPoW,
|
||||
}
|
||||
|
||||
impl PolicyResult {
|
||||
pub fn is_allowed(&self) -> bool {
|
||||
matches!(self, PolicyResult::Allowed)
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_allows_first_reservation() {
|
||||
let mut limiter = RateLimiter::new();
|
||||
let sender = [1u8; 16];
|
||||
|
||||
assert!(limiter.check_reservation(&sender).is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_enforces_cooldown() {
|
||||
let mut limiter = RateLimiter::with_limits(RateLimits {
|
||||
reservation_cooldown_secs: 300,
|
||||
..Default::default()
|
||||
});
|
||||
let sender = [2u8; 16];
|
||||
|
||||
limiter.record_reservation(&sender);
|
||||
let result = limiter.check_reservation(&sender);
|
||||
|
||||
assert!(matches!(result, RateLimitResult::Cooldown { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_enforces_hourly_limit() {
|
||||
let mut limiter = RateLimiter::with_limits(RateLimits {
|
||||
max_reservations_per_hour: 2,
|
||||
reservation_cooldown_secs: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let sender = [3u8; 16];
|
||||
|
||||
limiter.record_reservation(&sender);
|
||||
limiter.record_reservation(&sender);
|
||||
|
||||
assert_eq!(limiter.check_reservation(&sender), RateLimitResult::HourlyLimitReached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pow_generation_and_verification() {
|
||||
let reservation_id = [42u8; 16];
|
||||
let pow = ProofOfWork::generate(&reservation_id, 8); // Low difficulty for test
|
||||
|
||||
assert!(pow.verify(&reservation_id));
|
||||
assert!(!pow.verify(&[0u8; 16])); // Wrong ID
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reputation_tracking() {
|
||||
let mut rep = SenderReputation::new([5u8; 16]);
|
||||
|
||||
rep.record_honored();
|
||||
rep.record_honored();
|
||||
rep.record_no_show();
|
||||
|
||||
assert_eq!(rep.reservations_made, 3);
|
||||
assert_eq!(rep.honor_rate(), 2.0 / 3.0);
|
||||
assert!(!rep.is_blocked());
|
||||
|
||||
rep.record_no_show();
|
||||
rep.record_no_show();
|
||||
|
||||
assert!(rep.is_blocked()); // 3 no-shows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_check_pow() {
|
||||
let policy = TherapistPolicy {
|
||||
require_pow: true,
|
||||
pow_difficulty: 8,
|
||||
..Default::default()
|
||||
};
|
||||
let rep = SenderReputation::new([6u8; 16]);
|
||||
let reservation_id = [7u8; 16];
|
||||
|
||||
// No PoW
|
||||
assert_eq!(
|
||||
policy.check(&rep, 0, None, &reservation_id),
|
||||
PolicyResult::MissingPoW
|
||||
);
|
||||
|
||||
// Valid PoW
|
||||
let pow = ProofOfWork::generate(&reservation_id, 8);
|
||||
assert_eq!(
|
||||
policy.check(&rep, 0, Some(&pow), &reservation_id),
|
||||
PolicyResult::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_check_verification_level() {
|
||||
let policy = TherapistPolicy {
|
||||
min_verification_level: 2,
|
||||
require_pow: false,
|
||||
..Default::default()
|
||||
};
|
||||
let rep = SenderReputation::new([8u8; 16]);
|
||||
let reservation_id = [9u8; 16];
|
||||
|
||||
assert_eq!(
|
||||
policy.check(&rep, 1, None, &reservation_id),
|
||||
PolicyResult::InsufficientVerification
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
policy.check(&rep, 2, None, &reservation_id),
|
||||
PolicyResult::Allowed
|
||||
);
|
||||
}
|
||||
}
|
||||
55
crates/meshservice/src/error.rs
Normal file
55
crates/meshservice/src/error.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Error types for the mesh service layer.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur in the service layer.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("invalid message format: {0}")]
|
||||
InvalidFormat(String),
|
||||
|
||||
#[error("unknown service ID: {0}")]
|
||||
UnknownService(u32),
|
||||
|
||||
#[error("signature verification failed")]
|
||||
SignatureInvalid,
|
||||
|
||||
#[error("message expired")]
|
||||
Expired,
|
||||
|
||||
#[error("max hops exceeded")]
|
||||
MaxHopsExceeded,
|
||||
|
||||
#[error("missing capability: {0}")]
|
||||
MissingCapability(String),
|
||||
|
||||
#[error("store full")]
|
||||
StoreFull,
|
||||
|
||||
#[error("duplicate message")]
|
||||
Duplicate,
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("crypto error: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error("verification required: minimum level {0}")]
|
||||
VerificationRequired(u8),
|
||||
|
||||
#[error("service handler error: {0}")]
|
||||
Handler(String),
|
||||
}
|
||||
|
||||
impl From<ciborium::ser::Error<std::io::Error>> for ServiceError {
|
||||
fn from(e: ciborium::ser::Error<std::io::Error>) -> Self {
|
||||
ServiceError::Serialization(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ciborium::de::Error<std::io::Error>> for ServiceError {
|
||||
fn from(e: ciborium::de::Error<std::io::Error>) -> Self {
|
||||
ServiceError::Serialization(e.to_string())
|
||||
}
|
||||
}
|
||||
119
crates/meshservice/src/identity.rs
Normal file
119
crates/meshservice/src/identity.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! Service identity management using Ed25519.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use rand::rngs::OsRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// A service participant's identity (Ed25519 keypair).
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceIdentity {
|
||||
signing_key: SigningKey,
|
||||
}
|
||||
|
||||
impl ServiceIdentity {
|
||||
/// Generate a new random identity.
|
||||
pub fn generate() -> Self {
|
||||
use rand::RngCore;
|
||||
let mut secret = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut secret);
|
||||
let signing_key = SigningKey::from_bytes(&secret);
|
||||
Self { signing_key }
|
||||
}
|
||||
|
||||
/// Create from an existing secret key.
|
||||
pub fn from_secret(secret: &[u8; 32]) -> Self {
|
||||
let signing_key = SigningKey::from_bytes(secret);
|
||||
Self { signing_key }
|
||||
}
|
||||
|
||||
/// Get the 32-byte public key.
|
||||
pub fn public_key(&self) -> [u8; 32] {
|
||||
self.signing_key.verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
/// Get the 32-byte secret key (for persistence).
|
||||
pub fn secret_key(&self) -> [u8; 32] {
|
||||
self.signing_key.to_bytes()
|
||||
}
|
||||
|
||||
/// Compute the 16-byte mesh address from the public key.
|
||||
pub fn address(&self) -> [u8; 16] {
|
||||
compute_address(&self.public_key())
|
||||
}
|
||||
|
||||
/// Sign a message.
|
||||
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
|
||||
let sig = self.signing_key.sign(message);
|
||||
sig.to_bytes()
|
||||
}
|
||||
|
||||
/// Verify a signature against a public key.
|
||||
pub fn verify(public_key: &[u8; 32], message: &[u8], signature: &[u8; 64]) -> bool {
|
||||
let Ok(verifying_key) = VerifyingKey::from_bytes(public_key) else {
|
||||
return false;
|
||||
};
|
||||
let sig = Signature::from_bytes(signature);
|
||||
verifying_key.verify(message, &sig).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a 16-byte mesh address from a 32-byte public key.
|
||||
///
|
||||
/// Address = SHA-256(public_key)[0..16]
|
||||
pub fn compute_address(public_key: &[u8; 32]) -> [u8; 16] {
|
||||
let hash = Sha256::digest(public_key);
|
||||
let mut addr = [0u8; 16];
|
||||
addr.copy_from_slice(&hash[..16]);
|
||||
addr
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ServiceIdentity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ServiceIdentity")
|
||||
.field("address", &hex::encode(self.address()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// Hex encoding for debug output
|
||||
mod hex {
|
||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_and_sign() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = b"hello world";
|
||||
let sig = id.sign(msg);
|
||||
assert!(ServiceIdentity::verify(&id.public_key(), msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_is_deterministic() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let addr1 = id.address();
|
||||
let addr2 = compute_address(&id.public_key());
|
||||
assert_eq!(addr1, addr2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_message_fails() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let sig = id.sign(b"correct");
|
||||
assert!(!ServiceIdentity::verify(&id.public_key(), b"wrong", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_secret() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let secret = id.secret_key();
|
||||
let restored = ServiceIdentity::from_secret(&secret);
|
||||
assert_eq!(id.public_key(), restored.public_key());
|
||||
}
|
||||
}
|
||||
88
crates/meshservice/src/lib.rs
Normal file
88
crates/meshservice/src/lib.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! # MeshService — Generic Decentralized Service Layer
|
||||
//!
|
||||
//! A protocol and runtime for building decentralized services on mesh networks.
|
||||
//! Any service following the Announce → Query → Response → Reserve pattern
|
||||
//! can be implemented on this layer.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────┐
|
||||
//! │ Application Services │
|
||||
//! │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
//! │ │ FAPP │ │ Housing │ │ Repair │ │ Custom │ ... │
|
||||
//! │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
//! │ └────────────┴────────────┴────────────┘ │
|
||||
//! │ Service Layer (this crate) │
|
||||
//! │ ServiceMessage, ServiceRouter, Verification │
|
||||
//! │ ─────────────────────────────────────────────────────── │
|
||||
//! │ Mesh Layer │
|
||||
//! │ (provided by quicprochat-p2p or other mesh impl) │
|
||||
//! └─────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use meshservice::{ServiceRouter, ServiceMessage, services::fapp::FappService};
|
||||
//!
|
||||
//! // Create router
|
||||
//! let mut router = ServiceRouter::new(identity, capabilities);
|
||||
//!
|
||||
//! // Register services
|
||||
//! router.register(FappService::new());
|
||||
//! router.register(HousingService::new());
|
||||
//!
|
||||
//! // Handle incoming message
|
||||
//! let action = router.handle(&incoming_bytes);
|
||||
//! ```
|
||||
|
||||
pub mod identity;
|
||||
pub mod message;
|
||||
pub mod router;
|
||||
pub mod store;
|
||||
pub mod verification;
|
||||
pub mod services;
|
||||
pub mod wire;
|
||||
pub mod error;
|
||||
pub mod anti_abuse;
|
||||
|
||||
pub use identity::ServiceIdentity;
|
||||
pub use message::{ServiceMessage, MessageType};
|
||||
pub use router::{ServiceRouter, ServiceHandler, ServiceAction};
|
||||
pub use store::ServiceStore;
|
||||
pub use verification::{Verification, VerificationLevel};
|
||||
pub use error::ServiceError;
|
||||
pub use anti_abuse::{RateLimiter, RateLimits, ProofOfWork, SenderReputation, TherapistPolicy};
|
||||
|
||||
/// Well-known service IDs.
|
||||
pub mod service_ids {
|
||||
/// Free Appointment Propagation Protocol (psychotherapy).
|
||||
pub const FAPP: u32 = 0x0001;
|
||||
/// Housing / room sharing.
|
||||
pub const HOUSING: u32 = 0x0002;
|
||||
/// Repair services / craftsmen.
|
||||
pub const REPAIR: u32 = 0x0003;
|
||||
/// Tutoring / education.
|
||||
pub const TUTOR: u32 = 0x0004;
|
||||
/// Medical appointments.
|
||||
pub const MEDICAL: u32 = 0x0005;
|
||||
/// Legal consultation.
|
||||
pub const LEGAL: u32 = 0x0006;
|
||||
/// Volunteer coordination.
|
||||
pub const VOLUNTEER: u32 = 0x0007;
|
||||
/// Events / tickets.
|
||||
pub const EVENTS: u32 = 0x0008;
|
||||
/// Reserved for user-defined services.
|
||||
pub const CUSTOM_START: u32 = 0x8000;
|
||||
}
|
||||
|
||||
/// Capability flags for service participation.
|
||||
pub mod capabilities {
|
||||
/// Node can announce/provide services.
|
||||
pub const PROVIDER: u16 = 0x0100;
|
||||
/// Node caches and relays service messages.
|
||||
pub const RELAY: u16 = 0x0200;
|
||||
/// Node can query/consume services.
|
||||
pub const CONSUMER: u16 = 0x0400;
|
||||
}
|
||||
321
crates/meshservice/src/message.rs
Normal file
321
crates/meshservice/src/message.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! Core message types for the service layer.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::identity::ServiceIdentity;
|
||||
use crate::verification::Verification;
|
||||
|
||||
/// Message types within a service.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum MessageType {
|
||||
/// Provider announces availability.
|
||||
Announce = 0x01,
|
||||
/// Consumer queries for matches.
|
||||
Query = 0x02,
|
||||
/// Response to a query.
|
||||
Response = 0x03,
|
||||
/// Consumer reserves a slot/item.
|
||||
Reserve = 0x04,
|
||||
/// Provider confirms/rejects reservation.
|
||||
Confirm = 0x05,
|
||||
/// Either party cancels.
|
||||
Cancel = 0x06,
|
||||
/// Provider updates an existing announce (partial).
|
||||
Update = 0x07,
|
||||
/// Provider revokes an announce.
|
||||
Revoke = 0x08,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for MessageType {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0x01 => Ok(MessageType::Announce),
|
||||
0x02 => Ok(MessageType::Query),
|
||||
0x03 => Ok(MessageType::Response),
|
||||
0x04 => Ok(MessageType::Reserve),
|
||||
0x05 => Ok(MessageType::Confirm),
|
||||
0x06 => Ok(MessageType::Cancel),
|
||||
0x07 => Ok(MessageType::Update),
|
||||
0x08 => Ok(MessageType::Revoke),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic service message that can carry any application payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceMessage {
|
||||
/// Service identifier (which application).
|
||||
pub service_id: u32,
|
||||
/// Message type within service.
|
||||
pub message_type: MessageType,
|
||||
/// Protocol version for forward compatibility.
|
||||
pub version: u8,
|
||||
/// Unique message ID.
|
||||
pub id: [u8; 16],
|
||||
/// Sender's mesh address.
|
||||
pub sender_address: [u8; 16],
|
||||
/// Application-specific CBOR payload.
|
||||
pub payload: Vec<u8>,
|
||||
/// Ed25519 signature over signable fields.
|
||||
pub signature: Vec<u8>,
|
||||
/// Optional verifications from trusted parties.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub verifications: Vec<Verification>,
|
||||
/// Monotonically increasing per sender (dedup/supersede).
|
||||
pub sequence: u64,
|
||||
/// Time-to-live in hours.
|
||||
pub ttl_hours: u16,
|
||||
/// Unix timestamp of creation.
|
||||
pub timestamp: u64,
|
||||
/// Current hop count (incremented on re-broadcast).
|
||||
pub hop_count: u8,
|
||||
/// Maximum propagation hops.
|
||||
pub max_hops: u8,
|
||||
}
|
||||
|
||||
/// Default TTL: 7 days.
|
||||
const DEFAULT_TTL_HOURS: u16 = 168;
|
||||
/// Default max hops.
|
||||
const DEFAULT_MAX_HOPS: u8 = 8;
|
||||
|
||||
impl ServiceMessage {
|
||||
/// Create a new service message.
|
||||
pub fn new(
|
||||
identity: &ServiceIdentity,
|
||||
service_id: u32,
|
||||
message_type: MessageType,
|
||||
payload: Vec<u8>,
|
||||
sequence: u64,
|
||||
) -> Self {
|
||||
Self::with_options(
|
||||
identity,
|
||||
service_id,
|
||||
message_type,
|
||||
payload,
|
||||
sequence,
|
||||
DEFAULT_TTL_HOURS,
|
||||
DEFAULT_MAX_HOPS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create with custom TTL and max hops.
|
||||
pub fn with_options(
|
||||
identity: &ServiceIdentity,
|
||||
service_id: u32,
|
||||
message_type: MessageType,
|
||||
payload: Vec<u8>,
|
||||
sequence: u64,
|
||||
ttl_hours: u16,
|
||||
max_hops: u8,
|
||||
) -> Self {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let sender_address = identity.address();
|
||||
|
||||
// Generate unique ID from address + sequence
|
||||
let id_hash = Sha256::digest(
|
||||
[&sender_address[..], &sequence.to_le_bytes()].concat()
|
||||
);
|
||||
let mut id = [0u8; 16];
|
||||
id.copy_from_slice(&id_hash[..16]);
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut msg = Self {
|
||||
service_id,
|
||||
message_type,
|
||||
version: 1,
|
||||
id,
|
||||
sender_address,
|
||||
payload,
|
||||
signature: Vec::new(),
|
||||
verifications: Vec::new(),
|
||||
sequence,
|
||||
ttl_hours,
|
||||
timestamp,
|
||||
hop_count: 0,
|
||||
max_hops,
|
||||
};
|
||||
|
||||
let signable = msg.signable_bytes();
|
||||
msg.signature = identity.sign(&signable).to_vec();
|
||||
msg
|
||||
}
|
||||
|
||||
/// Create an announce message.
|
||||
pub fn announce(
|
||||
identity: &ServiceIdentity,
|
||||
service_id: u32,
|
||||
payload: Vec<u8>,
|
||||
sequence: u64,
|
||||
) -> Self {
|
||||
Self::new(identity, service_id, MessageType::Announce, payload, sequence)
|
||||
}
|
||||
|
||||
/// Create a query message.
|
||||
pub fn query(
|
||||
identity: &ServiceIdentity,
|
||||
service_id: u32,
|
||||
payload: Vec<u8>,
|
||||
) -> Self {
|
||||
// Queries use random sequence (not monotonic)
|
||||
let sequence = rand::random();
|
||||
Self::with_options(
|
||||
identity,
|
||||
service_id,
|
||||
MessageType::Query,
|
||||
payload,
|
||||
sequence,
|
||||
1, // 1 hour TTL for queries
|
||||
DEFAULT_MAX_HOPS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a response message.
|
||||
pub fn response(
|
||||
identity: &ServiceIdentity,
|
||||
service_id: u32,
|
||||
query_id: [u8; 16],
|
||||
payload: Vec<u8>,
|
||||
) -> Self {
|
||||
let mut msg = Self::new(
|
||||
identity,
|
||||
service_id,
|
||||
MessageType::Response,
|
||||
payload,
|
||||
rand::random(),
|
||||
);
|
||||
// Response ID matches query ID for correlation
|
||||
msg.id = query_id;
|
||||
msg
|
||||
}
|
||||
|
||||
/// Assemble bytes for signing/verification.
|
||||
/// Excludes signature, hop_count, verifications (mutable fields).
|
||||
fn signable_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
buf.extend_from_slice(&self.service_id.to_le_bytes());
|
||||
buf.push(self.message_type as u8);
|
||||
buf.push(self.version);
|
||||
buf.extend_from_slice(&self.id);
|
||||
buf.extend_from_slice(&self.sender_address);
|
||||
buf.extend_from_slice(&(self.payload.len() as u32).to_le_bytes());
|
||||
buf.extend_from_slice(&self.payload);
|
||||
buf.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
buf.extend_from_slice(&self.ttl_hours.to_le_bytes());
|
||||
buf.extend_from_slice(&self.timestamp.to_le_bytes());
|
||||
buf.push(self.max_hops);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Verify the signature using the sender's public key.
|
||||
pub fn verify(&self, sender_public_key: &[u8; 32]) -> bool {
|
||||
use crate::identity::compute_address;
|
||||
|
||||
// Verify address matches key
|
||||
if compute_address(sender_public_key) != self.sender_address {
|
||||
return false;
|
||||
}
|
||||
|
||||
let sig: [u8; 64] = match self.signature.as_slice().try_into() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let signable = self.signable_bytes();
|
||||
ServiceIdentity::verify(sender_public_key, &signable, &sig)
|
||||
}
|
||||
|
||||
/// Check if the message has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let ttl_secs = u64::from(self.ttl_hours) * 3600;
|
||||
now.saturating_sub(self.timestamp) > ttl_secs
|
||||
}
|
||||
|
||||
/// Check if the message can still propagate.
|
||||
pub fn can_propagate(&self) -> bool {
|
||||
self.hop_count < self.max_hops && !self.is_expired()
|
||||
}
|
||||
|
||||
/// Create a forwarded copy with incremented hop count.
|
||||
pub fn forwarded(&self) -> Self {
|
||||
let mut copy = self.clone();
|
||||
copy.hop_count = copy.hop_count.saturating_add(1);
|
||||
copy
|
||||
}
|
||||
|
||||
/// Get the highest verification level attached.
|
||||
pub fn verification_level(&self) -> u8 {
|
||||
self.verifications
|
||||
.iter()
|
||||
.map(|v| v.level)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Add a verification to the message.
|
||||
pub fn add_verification(&mut self, verification: Verification) {
|
||||
self.verifications.push(verification);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_and_verify() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::announce(
|
||||
&id,
|
||||
crate::service_ids::FAPP,
|
||||
b"test payload".to_vec(),
|
||||
1,
|
||||
);
|
||||
|
||||
assert!(msg.verify(&id.public_key()));
|
||||
assert!(!msg.is_expired());
|
||||
assert!(msg.can_propagate());
|
||||
assert_eq!(msg.hop_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_increments_hop() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::announce(&id, 1, vec![], 1);
|
||||
let fwd = msg.forwarded();
|
||||
|
||||
assert_eq!(fwd.hop_count, 1);
|
||||
assert!(fwd.verify(&id.public_key())); // Still valid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_fails_verify() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let mut msg = ServiceMessage::announce(&id, 1, b"original".to_vec(), 1);
|
||||
msg.payload = b"tampered".to_vec();
|
||||
|
||||
assert!(!msg.verify(&id.public_key()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_has_short_ttl() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::query(&id, 1, vec![]);
|
||||
|
||||
assert_eq!(msg.ttl_hours, 1);
|
||||
}
|
||||
}
|
||||
289
crates/meshservice/src/router.rs
Normal file
289
crates/meshservice/src/router.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
//! Service router dispatches messages to service-specific handlers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::ServiceError;
|
||||
use crate::message::{MessageType, ServiceMessage};
|
||||
use crate::store::{ServiceStore, StoredMessage};
|
||||
use crate::verification::TrustedVerifiers;
|
||||
|
||||
/// Action returned by a service handler.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceAction {
|
||||
/// Message handled, do nothing more.
|
||||
Handled,
|
||||
/// Store the message locally.
|
||||
Store,
|
||||
/// Store and forward to peers.
|
||||
StoreAndForward,
|
||||
/// Forward without storing (pass-through relay).
|
||||
ForwardOnly,
|
||||
/// Drop the message silently.
|
||||
Drop,
|
||||
/// Send a response back.
|
||||
Respond(ServiceMessage),
|
||||
/// Reject with error.
|
||||
Reject(ServiceError),
|
||||
}
|
||||
|
||||
/// Trait for service-specific handlers.
|
||||
pub trait ServiceHandler: Send + Sync {
|
||||
/// The service ID this handler manages.
|
||||
fn service_id(&self) -> u32;
|
||||
|
||||
/// Human-readable service name.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Handle an incoming message.
|
||||
fn handle(
|
||||
&self,
|
||||
message: &ServiceMessage,
|
||||
context: &HandlerContext,
|
||||
) -> Result<ServiceAction, ServiceError>;
|
||||
|
||||
/// Validate a message payload (service-specific logic).
|
||||
fn validate(&self, message: &ServiceMessage) -> Result<(), ServiceError> {
|
||||
// Default: accept all
|
||||
let _ = message;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a message matches a query.
|
||||
fn matches_query(&self, announce: &StoredMessage, query: &ServiceMessage) -> bool;
|
||||
}
|
||||
|
||||
/// Context passed to handlers.
|
||||
pub struct HandlerContext<'a> {
|
||||
/// Current node's capabilities.
|
||||
pub capabilities: u16,
|
||||
/// The store (for lookups during handle).
|
||||
pub store: &'a ServiceStore,
|
||||
/// Trusted verifiers for checking.
|
||||
pub trusted_verifiers: &'a TrustedVerifiers,
|
||||
/// Sender's public key (if known).
|
||||
pub sender_public_key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Routes messages to appropriate service handlers.
|
||||
pub struct ServiceRouter {
|
||||
/// Service ID -> Handler.
|
||||
handlers: HashMap<u32, Box<dyn ServiceHandler>>,
|
||||
/// Shared message store.
|
||||
store: ServiceStore,
|
||||
/// Node capabilities.
|
||||
capabilities: u16,
|
||||
/// Trusted verifiers.
|
||||
trusted_verifiers: TrustedVerifiers,
|
||||
/// Minimum verification level to accept announces (0 = any).
|
||||
min_verification_level: u8,
|
||||
}
|
||||
|
||||
impl ServiceRouter {
|
||||
/// Create a new router.
|
||||
pub fn new(capabilities: u16) -> Self {
|
||||
Self {
|
||||
handlers: HashMap::new(),
|
||||
store: ServiceStore::new(),
|
||||
capabilities,
|
||||
trusted_verifiers: TrustedVerifiers::new(),
|
||||
min_verification_level: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a service handler.
|
||||
pub fn register(&mut self, handler: Box<dyn ServiceHandler>) {
|
||||
let id = handler.service_id();
|
||||
self.handlers.insert(id, handler);
|
||||
}
|
||||
|
||||
/// Set trusted verifiers.
|
||||
pub fn set_trusted_verifiers(&mut self, verifiers: TrustedVerifiers) {
|
||||
self.trusted_verifiers = verifiers;
|
||||
}
|
||||
|
||||
/// Set minimum verification level for announces.
|
||||
pub fn set_min_verification_level(&mut self, level: u8) {
|
||||
self.min_verification_level = level;
|
||||
}
|
||||
|
||||
/// Access the store.
|
||||
pub fn store(&self) -> &ServiceStore {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// Mutable access to store.
|
||||
pub fn store_mut(&mut self) -> &mut ServiceStore {
|
||||
&mut self.store
|
||||
}
|
||||
|
||||
/// Check if a service is registered.
|
||||
pub fn has_service(&self, service_id: u32) -> bool {
|
||||
self.handlers.contains_key(&service_id)
|
||||
}
|
||||
|
||||
/// Handle an incoming message.
|
||||
pub fn handle(
|
||||
&mut self,
|
||||
message: ServiceMessage,
|
||||
sender_public_key: Option<[u8; 32]>,
|
||||
) -> Result<ServiceAction, ServiceError> {
|
||||
// Basic validation
|
||||
if message.is_expired() {
|
||||
return Err(ServiceError::Expired);
|
||||
}
|
||||
|
||||
if message.hop_count > message.max_hops {
|
||||
return Err(ServiceError::MaxHopsExceeded);
|
||||
}
|
||||
|
||||
// Get handler
|
||||
let handler = self
|
||||
.handlers
|
||||
.get(&message.service_id)
|
||||
.ok_or(ServiceError::UnknownService(message.service_id))?;
|
||||
|
||||
// Validate message with handler
|
||||
handler.validate(&message)?;
|
||||
|
||||
// Verify signature if we have public key
|
||||
if let Some(pk) = &sender_public_key {
|
||||
if !message.verify(pk) {
|
||||
return Err(ServiceError::SignatureInvalid);
|
||||
}
|
||||
}
|
||||
|
||||
// Check verification level for announces
|
||||
if message.message_type == MessageType::Announce && self.min_verification_level > 0 {
|
||||
let level = self
|
||||
.trusted_verifiers
|
||||
.highest_level(&message.verifications, &message.sender_address);
|
||||
if (level as u8) < self.min_verification_level {
|
||||
return Err(ServiceError::VerificationRequired(self.min_verification_level));
|
||||
}
|
||||
}
|
||||
|
||||
// Build context
|
||||
let context = HandlerContext {
|
||||
capabilities: self.capabilities,
|
||||
store: &self.store,
|
||||
trusted_verifiers: &self.trusted_verifiers,
|
||||
sender_public_key,
|
||||
};
|
||||
|
||||
// Dispatch to handler
|
||||
let action = handler.handle(&message, &context)?;
|
||||
|
||||
// Process action
|
||||
match &action {
|
||||
ServiceAction::Store | ServiceAction::StoreAndForward => {
|
||||
if let Some(pk) = sender_public_key {
|
||||
self.store.store(message, pk);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
/// Query the store for matching announces.
|
||||
pub fn query(&self, query: &ServiceMessage) -> Vec<&StoredMessage> {
|
||||
let Some(handler) = self.handlers.get(&query.service_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
self.store.query(query.service_id, |stored| {
|
||||
stored.message.message_type == MessageType::Announce
|
||||
&& handler.matches_query(stored, query)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get handler name for a service.
|
||||
pub fn service_name(&self, service_id: u32) -> Option<&str> {
|
||||
self.handlers.get(&service_id).map(|h| h.name())
|
||||
}
|
||||
|
||||
/// List registered services.
|
||||
pub fn services(&self) -> Vec<(u32, &str)> {
|
||||
self.handlers
|
||||
.iter()
|
||||
.map(|(&id, h)| (id, h.name()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{identity::ServiceIdentity, service_ids::FAPP};
|
||||
|
||||
struct TestHandler;
|
||||
|
||||
impl ServiceHandler for TestHandler {
|
||||
fn service_id(&self) -> u32 {
|
||||
FAPP
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"Test"
|
||||
}
|
||||
|
||||
fn handle(
|
||||
&self,
|
||||
message: &ServiceMessage,
|
||||
_context: &HandlerContext,
|
||||
) -> Result<ServiceAction, ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => Ok(ServiceAction::StoreAndForward),
|
||||
MessageType::Query => Ok(ServiceAction::Handled),
|
||||
_ => Ok(ServiceAction::Drop),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_query(&self, _announce: &StoredMessage, _query: &ServiceMessage) -> bool {
|
||||
true // Match all for test
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_handle() {
|
||||
let mut router = ServiceRouter::new(crate::capabilities::RELAY);
|
||||
router.register(Box::new(TestHandler));
|
||||
|
||||
assert!(router.has_service(FAPP));
|
||||
assert_eq!(router.service_name(FAPP), Some("Test"));
|
||||
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::announce(&id, FAPP, vec![], 1);
|
||||
|
||||
let action = router.handle(msg.clone(), Some(id.public_key())).unwrap();
|
||||
assert!(matches!(action, ServiceAction::StoreAndForward));
|
||||
|
||||
// Message should be stored
|
||||
assert_eq!(router.store().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_service_rejected() {
|
||||
let mut router = ServiceRouter::new(0);
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::announce(&id, 9999, vec![], 1);
|
||||
|
||||
let result = router.handle(msg, Some(id.public_key()));
|
||||
assert!(matches!(result, Err(ServiceError::UnknownService(9999))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_signature_rejected() {
|
||||
let mut router = ServiceRouter::new(0);
|
||||
router.register(Box::new(TestHandler));
|
||||
|
||||
let id1 = ServiceIdentity::generate();
|
||||
let id2 = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::announce(&id1, FAPP, vec![], 1);
|
||||
|
||||
// Pass wrong public key
|
||||
let result = router.handle(msg, Some(id2.public_key()));
|
||||
assert!(matches!(result, Err(ServiceError::SignatureInvalid)));
|
||||
}
|
||||
}
|
||||
479
crates/meshservice/src/services/fapp.rs
Normal file
479
crates/meshservice/src/services/fapp.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
//! FAPP — Free Appointment Propagation Protocol.
|
||||
//!
|
||||
//! Decentralized psychotherapy appointment discovery.
|
||||
//!
|
||||
//! ## Flow
|
||||
//!
|
||||
//! 1. Therapist announces available slots (specialism, location, modality).
|
||||
//! 2. Announcement floods through mesh (TTL-limited, signature-verified).
|
||||
//! 3. Patient queries for matching slots (specialism, distance).
|
||||
//! 4. Relays respond with cached matches.
|
||||
//! 5. Patient reserves slot (E2E encrypted to therapist).
|
||||
//! 6. Therapist confirms/rejects.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ServiceError;
|
||||
use crate::message::{MessageType, ServiceMessage};
|
||||
use crate::router::{HandlerContext, ServiceAction, ServiceHandler};
|
||||
use crate::service_ids::FAPP;
|
||||
use crate::store::StoredMessage;
|
||||
use crate::wire::{decode_payload, encode_payload};
|
||||
|
||||
/// Therapy specialisms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Specialism {
|
||||
GeneralPsychotherapy = 0x01,
|
||||
CognitiveBehavioral = 0x02,
|
||||
Psychoanalysis = 0x03,
|
||||
SystemicTherapy = 0x04,
|
||||
TraumaFocused = 0x05,
|
||||
ChildAndAdolescent = 0x06,
|
||||
CoupleAndFamily = 0x07,
|
||||
Addiction = 0x08,
|
||||
Neuropsychology = 0x09,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for Specialism {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0x01 => Ok(Self::GeneralPsychotherapy),
|
||||
0x02 => Ok(Self::CognitiveBehavioral),
|
||||
0x03 => Ok(Self::Psychoanalysis),
|
||||
0x04 => Ok(Self::SystemicTherapy),
|
||||
0x05 => Ok(Self::TraumaFocused),
|
||||
0x06 => Ok(Self::ChildAndAdolescent),
|
||||
0x07 => Ok(Self::CoupleAndFamily),
|
||||
0x08 => Ok(Self::Addiction),
|
||||
0x09 => Ok(Self::Neuropsychology),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Therapy modality.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Modality {
|
||||
InPerson = 0x01,
|
||||
VideoCall = 0x02,
|
||||
PhoneCall = 0x03,
|
||||
TextBased = 0x04,
|
||||
}
|
||||
|
||||
/// Slot announcement payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotAnnounce {
|
||||
/// Therapist's specialisms (bitfield).
|
||||
pub specialisms: u16,
|
||||
/// Modality (bitfield).
|
||||
pub modality: u8,
|
||||
/// Postal code (first 3 digits for privacy).
|
||||
pub postal_prefix: String,
|
||||
/// Geohash (6 chars, ~1.2km precision).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geohash: Option<String>,
|
||||
/// Available slots count.
|
||||
pub available_slots: u8,
|
||||
/// Earliest available date (days from epoch).
|
||||
pub earliest_days: u16,
|
||||
/// Insurance types accepted (bitfield).
|
||||
pub insurance: u8,
|
||||
/// Optional profile URL for verification.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_url: Option<String>,
|
||||
/// Optional display name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl SlotAnnounce {
|
||||
/// Create a new announcement.
|
||||
pub fn new(specialisms: &[Specialism], modality: Modality, postal_prefix: &str) -> Self {
|
||||
let spec_bits = specialisms.iter().fold(0u16, |acc, s| acc | (1 << (*s as u8)));
|
||||
|
||||
Self {
|
||||
specialisms: spec_bits,
|
||||
modality: modality as u8,
|
||||
postal_prefix: postal_prefix.into(),
|
||||
geohash: None,
|
||||
available_slots: 1,
|
||||
earliest_days: 0,
|
||||
insurance: 0xFF, // All accepted by default
|
||||
profile_url: None,
|
||||
display_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set geohash location.
|
||||
pub fn with_geohash(mut self, geohash: &str) -> Self {
|
||||
self.geohash = Some(geohash[..6.min(geohash.len())].into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set available slots count.
|
||||
pub fn with_slots(mut self, count: u8) -> Self {
|
||||
self.available_slots = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set earliest availability.
|
||||
pub fn with_earliest(mut self, days_from_now: u16) -> Self {
|
||||
self.earliest_days = days_from_now;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set profile URL.
|
||||
pub fn with_profile(mut self, url: &str) -> Self {
|
||||
self.profile_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set display name.
|
||||
pub fn with_name(mut self, name: &str) -> Self {
|
||||
self.display_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a specialism is offered.
|
||||
pub fn has_specialism(&self, spec: Specialism) -> bool {
|
||||
self.specialisms & (1 << (spec as u8)) != 0
|
||||
}
|
||||
|
||||
/// Encode to CBOR bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_payload(self)
|
||||
}
|
||||
|
||||
/// Decode from CBOR bytes.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, ServiceError> {
|
||||
decode_payload(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Insurance types.
|
||||
pub mod insurance {
|
||||
pub const PRIVATE: u8 = 0x01;
|
||||
pub const PUBLIC: u8 = 0x02;
|
||||
pub const SELF_PAY: u8 = 0x04;
|
||||
}
|
||||
|
||||
/// Slot query payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotQuery {
|
||||
/// Desired specialisms (bitfield, any match).
|
||||
pub specialisms: u16,
|
||||
/// Postal prefix to search.
|
||||
pub postal_prefix: String,
|
||||
/// Max distance in km (optional).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_distance_km: Option<u8>,
|
||||
/// Required modality (0 = any).
|
||||
pub modality: u8,
|
||||
/// Max wait in days.
|
||||
pub max_wait_days: u16,
|
||||
/// Insurance type required.
|
||||
pub insurance: u8,
|
||||
}
|
||||
|
||||
impl SlotQuery {
|
||||
/// Create a query for a specialism in a postal area.
|
||||
pub fn new(specialism: Specialism, postal_prefix: &str) -> Self {
|
||||
Self {
|
||||
specialisms: 1 << (specialism as u8),
|
||||
postal_prefix: postal_prefix.into(),
|
||||
max_distance_km: None,
|
||||
modality: 0,
|
||||
max_wait_days: 365,
|
||||
insurance: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Require specific modality.
|
||||
pub fn with_modality(mut self, modality: Modality) -> Self {
|
||||
self.modality = modality as u8;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max wait time.
|
||||
pub fn with_max_wait(mut self, days: u16) -> Self {
|
||||
self.max_wait_days = days;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if an announce matches this query.
|
||||
pub fn matches(&self, announce: &SlotAnnounce) -> bool {
|
||||
// Specialism overlap
|
||||
if announce.specialisms & self.specialisms == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Postal prefix
|
||||
if !announce.postal_prefix.starts_with(&self.postal_prefix)
|
||||
&& !self.postal_prefix.starts_with(&announce.postal_prefix)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Modality
|
||||
if self.modality != 0 && announce.modality & self.modality == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait time
|
||||
if announce.earliest_days > self.max_wait_days {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Insurance
|
||||
if announce.insurance & self.insurance == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Available slots
|
||||
announce.available_slots > 0
|
||||
}
|
||||
|
||||
/// Encode to CBOR bytes.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_payload(self)
|
||||
}
|
||||
|
||||
/// Decode from CBOR bytes.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, ServiceError> {
|
||||
decode_payload(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// FAPP service handler.
|
||||
pub struct FappService {
|
||||
/// Whether this node is a therapist (can announce).
|
||||
pub is_provider: bool,
|
||||
/// Whether this node relays FAPP messages.
|
||||
pub is_relay: bool,
|
||||
}
|
||||
|
||||
impl FappService {
|
||||
/// Create a new FAPP handler.
|
||||
pub fn new(is_provider: bool, is_relay: bool) -> Self {
|
||||
Self {
|
||||
is_provider,
|
||||
is_relay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a relay-only handler.
|
||||
pub fn relay() -> Self {
|
||||
Self::new(false, true)
|
||||
}
|
||||
|
||||
/// Create a provider handler.
|
||||
pub fn provider() -> Self {
|
||||
Self::new(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceHandler for FappService {
|
||||
fn service_id(&self) -> u32 {
|
||||
FAPP
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"FAPP"
|
||||
}
|
||||
|
||||
fn handle(
|
||||
&self,
|
||||
message: &ServiceMessage,
|
||||
context: &HandlerContext,
|
||||
) -> Result<ServiceAction, ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => {
|
||||
// Validate payload
|
||||
let _announce = SlotAnnounce::from_bytes(&message.payload)?;
|
||||
|
||||
// Store and forward if we're a relay
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::StoreAndForward)
|
||||
} else {
|
||||
Ok(ServiceAction::Store)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Query => {
|
||||
// Parse query
|
||||
let query = SlotQuery::from_bytes(&message.payload)?;
|
||||
|
||||
// Find matches in store
|
||||
let matches: Vec<_> = context
|
||||
.store
|
||||
.by_service(FAPP)
|
||||
.into_iter()
|
||||
.filter(|stored| {
|
||||
if stored.message.message_type != MessageType::Announce {
|
||||
return false;
|
||||
}
|
||||
if let Ok(announce) = SlotAnnounce::from_bytes(&stored.message.payload) {
|
||||
query.matches(&announce)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// If we have matches, we could respond (simplified for now)
|
||||
if !matches.is_empty() {
|
||||
// In a real impl, we'd aggregate and send response
|
||||
Ok(ServiceAction::Handled)
|
||||
} else if self.is_relay {
|
||||
Ok(ServiceAction::ForwardOnly)
|
||||
} else {
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Reserve | MessageType::Confirm | MessageType::Cancel => {
|
||||
// E2E encrypted, just forward
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::ForwardOnly)
|
||||
} else {
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Revoke => {
|
||||
// Remove from store
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
|
||||
_ => Ok(ServiceAction::Drop),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self, message: &ServiceMessage) -> Result<(), ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => {
|
||||
SlotAnnounce::from_bytes(&message.payload)?;
|
||||
}
|
||||
MessageType::Query => {
|
||||
SlotQuery::from_bytes(&message.payload)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_query(&self, announce: &StoredMessage, query_msg: &ServiceMessage) -> bool {
|
||||
let Ok(announce_data) = SlotAnnounce::from_bytes(&announce.message.payload) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(query) = SlotQuery::from_bytes(&query_msg.payload) else {
|
||||
return false;
|
||||
};
|
||||
query.matches(&announce_data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a FAPP announce message.
|
||||
pub fn create_announce(
|
||||
identity: &crate::ServiceIdentity,
|
||||
announce: &SlotAnnounce,
|
||||
sequence: u64,
|
||||
) -> Result<ServiceMessage, ServiceError> {
|
||||
let payload = announce.to_bytes()?;
|
||||
Ok(ServiceMessage::announce(identity, FAPP, payload, sequence))
|
||||
}
|
||||
|
||||
/// Helper to create a FAPP query message.
|
||||
pub fn create_query(
|
||||
identity: &crate::ServiceIdentity,
|
||||
query: &SlotQuery,
|
||||
) -> Result<ServiceMessage, ServiceError> {
|
||||
let payload = query.to_bytes()?;
|
||||
Ok(ServiceMessage::query(identity, FAPP, payload))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::ServiceIdentity;
|
||||
|
||||
#[test]
|
||||
fn slot_announce_roundtrip() {
|
||||
let announce = SlotAnnounce::new(
|
||||
&[Specialism::CognitiveBehavioral, Specialism::TraumaFocused],
|
||||
Modality::VideoCall,
|
||||
"104",
|
||||
)
|
||||
.with_slots(3)
|
||||
.with_profile("https://therapists.de/dr-mueller");
|
||||
|
||||
let bytes = announce.to_bytes().unwrap();
|
||||
let decoded = SlotAnnounce::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert!(decoded.has_specialism(Specialism::CognitiveBehavioral));
|
||||
assert!(decoded.has_specialism(Specialism::TraumaFocused));
|
||||
assert!(!decoded.has_specialism(Specialism::Addiction));
|
||||
assert_eq!(decoded.available_slots, 3);
|
||||
assert_eq!(
|
||||
decoded.profile_url,
|
||||
Some("https://therapists.de/dr-mueller".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_matches_announce() {
|
||||
let announce = SlotAnnounce::new(
|
||||
&[Specialism::CognitiveBehavioral],
|
||||
Modality::InPerson,
|
||||
"104",
|
||||
)
|
||||
.with_slots(2);
|
||||
|
||||
let matching_query = SlotQuery::new(Specialism::CognitiveBehavioral, "104");
|
||||
assert!(matching_query.matches(&announce));
|
||||
|
||||
let wrong_spec = SlotQuery::new(Specialism::Addiction, "104");
|
||||
assert!(!wrong_spec.matches(&announce));
|
||||
|
||||
let wrong_location = SlotQuery::new(Specialism::CognitiveBehavioral, "200");
|
||||
assert!(!wrong_location.matches(&announce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_message_helpers() {
|
||||
let id = ServiceIdentity::generate();
|
||||
|
||||
let announce = SlotAnnounce::new(&[Specialism::GeneralPsychotherapy], Modality::VideoCall, "10");
|
||||
let msg = create_announce(&id, &announce, 1).unwrap();
|
||||
assert_eq!(msg.service_id, FAPP);
|
||||
assert_eq!(msg.message_type, MessageType::Announce);
|
||||
|
||||
let query = SlotQuery::new(Specialism::GeneralPsychotherapy, "10");
|
||||
let msg = create_query(&id, &query).unwrap();
|
||||
assert_eq!(msg.service_id, FAPP);
|
||||
assert_eq!(msg.message_type, MessageType::Query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fapp_handler_processes_announce() {
|
||||
use crate::router::ServiceRouter;
|
||||
use crate::capabilities;
|
||||
|
||||
let mut router = ServiceRouter::new(capabilities::RELAY);
|
||||
router.register(Box::new(FappService::relay()));
|
||||
|
||||
let id = ServiceIdentity::generate();
|
||||
let announce = SlotAnnounce::new(&[Specialism::TraumaFocused], Modality::InPerson, "100");
|
||||
let msg = create_announce(&id, &announce, 1).unwrap();
|
||||
|
||||
let action = router.handle(msg.clone(), Some(id.public_key())).unwrap();
|
||||
assert!(matches!(action, ServiceAction::StoreAndForward));
|
||||
|
||||
// Should be stored
|
||||
assert_eq!(router.store().service_count(FAPP), 1);
|
||||
}
|
||||
}
|
||||
489
crates/meshservice/src/services/housing.rs
Normal file
489
crates/meshservice/src/services/housing.rs
Normal file
@@ -0,0 +1,489 @@
|
||||
//! Housing Service — Decentralized room/apartment sharing.
|
||||
//!
|
||||
//! Demonstrates how a second service can be built on the mesh layer.
|
||||
//!
|
||||
//! ## Flow
|
||||
//!
|
||||
//! 1. Landlord announces available room (type, size, price, location).
|
||||
//! 2. Announcement floods through mesh.
|
||||
//! 3. Seeker queries for matching listings.
|
||||
//! 4. Relays respond with cached matches.
|
||||
//! 5. Seeker reserves viewing slot (E2E encrypted).
|
||||
//! 6. Landlord confirms/rejects.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ServiceError;
|
||||
use crate::message::{MessageType, ServiceMessage};
|
||||
use crate::router::{HandlerContext, ServiceAction, ServiceHandler};
|
||||
use crate::service_ids::HOUSING;
|
||||
use crate::store::StoredMessage;
|
||||
use crate::wire::{decode_payload, encode_payload};
|
||||
|
||||
/// Listing type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum ListingType {
|
||||
Room = 0x01,
|
||||
SharedFlat = 0x02,
|
||||
Apartment = 0x03,
|
||||
House = 0x04,
|
||||
Studio = 0x05,
|
||||
Sublet = 0x06,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for ListingType {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0x01 => Ok(Self::Room),
|
||||
0x02 => Ok(Self::SharedFlat),
|
||||
0x03 => Ok(Self::Apartment),
|
||||
0x04 => Ok(Self::House),
|
||||
0x05 => Ok(Self::Studio),
|
||||
0x06 => Ok(Self::Sublet),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Amenities bitfield.
|
||||
pub mod amenities {
|
||||
pub const FURNISHED: u16 = 0x0001;
|
||||
pub const BALCONY: u16 = 0x0002;
|
||||
pub const PARKING: u16 = 0x0004;
|
||||
pub const PETS_ALLOWED: u16 = 0x0008;
|
||||
pub const WASHING_MACHINE: u16 = 0x0010;
|
||||
pub const DISHWASHER: u16 = 0x0020;
|
||||
pub const ELEVATOR: u16 = 0x0040;
|
||||
pub const GARDEN: u16 = 0x0080;
|
||||
pub const INTERNET: u16 = 0x0100;
|
||||
pub const HEATING_INCLUDED: u16 = 0x0200;
|
||||
}
|
||||
|
||||
/// Room/listing announcement.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListingAnnounce {
|
||||
/// Type of listing.
|
||||
pub listing_type: u8,
|
||||
/// Size in square meters.
|
||||
pub size_sqm: u16,
|
||||
/// Monthly rent in cents (EUR).
|
||||
pub rent_cents: u32,
|
||||
/// Postal prefix (3 digits).
|
||||
pub postal_prefix: String,
|
||||
/// Geohash for location (6 chars).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geohash: Option<String>,
|
||||
/// Number of rooms (0 for studio).
|
||||
pub rooms: u8,
|
||||
/// Available from (days from epoch).
|
||||
pub available_from_days: u16,
|
||||
/// Minimum rental period in months (0 = unlimited).
|
||||
pub min_months: u8,
|
||||
/// Maximum rental period in months (0 = unlimited).
|
||||
pub max_months: u8,
|
||||
/// Amenities bitfield.
|
||||
pub amenities: u16,
|
||||
/// Optional title.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
/// Optional external listing URL.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub listing_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ListingAnnounce {
|
||||
/// Create a new listing.
|
||||
pub fn new(listing_type: ListingType, size_sqm: u16, rent_euros: u32, postal_prefix: &str) -> Self {
|
||||
Self {
|
||||
listing_type: listing_type as u8,
|
||||
size_sqm,
|
||||
rent_cents: rent_euros * 100,
|
||||
postal_prefix: postal_prefix.into(),
|
||||
geohash: None,
|
||||
rooms: 1,
|
||||
available_from_days: 0,
|
||||
min_months: 0,
|
||||
max_months: 0,
|
||||
amenities: 0,
|
||||
title: None,
|
||||
listing_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set rooms count.
|
||||
pub fn with_rooms(mut self, rooms: u8) -> Self {
|
||||
self.rooms = rooms;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set geohash.
|
||||
pub fn with_geohash(mut self, geohash: &str) -> Self {
|
||||
self.geohash = Some(geohash[..6.min(geohash.len())].into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set amenities.
|
||||
pub fn with_amenities(mut self, amenities: u16) -> Self {
|
||||
self.amenities = amenities;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set title.
|
||||
pub fn with_title(mut self, title: &str) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set minimum/maximum rental period.
|
||||
pub fn with_term(mut self, min_months: u8, max_months: u8) -> Self {
|
||||
self.min_months = min_months;
|
||||
self.max_months = max_months;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if has amenity.
|
||||
pub fn has_amenity(&self, amenity: u16) -> bool {
|
||||
self.amenities & amenity != 0
|
||||
}
|
||||
|
||||
/// Get rent in euros.
|
||||
pub fn rent_euros(&self) -> u32 {
|
||||
self.rent_cents / 100
|
||||
}
|
||||
|
||||
/// Encode to CBOR.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_payload(self)
|
||||
}
|
||||
|
||||
/// Decode from CBOR.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, ServiceError> {
|
||||
decode_payload(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Housing query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListingQuery {
|
||||
/// Desired listing types (bitfield).
|
||||
pub listing_types: u8,
|
||||
/// Postal prefix.
|
||||
pub postal_prefix: String,
|
||||
/// Min size in sqm.
|
||||
pub min_size_sqm: u16,
|
||||
/// Max rent in cents.
|
||||
pub max_rent_cents: u32,
|
||||
/// Min rooms.
|
||||
pub min_rooms: u8,
|
||||
/// Required amenities (all must match).
|
||||
pub required_amenities: u16,
|
||||
/// Max move-in days.
|
||||
pub max_move_in_days: u16,
|
||||
}
|
||||
|
||||
impl ListingQuery {
|
||||
/// Create a simple query.
|
||||
pub fn new(postal_prefix: &str, max_rent_euros: u32) -> Self {
|
||||
Self {
|
||||
listing_types: 0xFF, // Any type
|
||||
postal_prefix: postal_prefix.into(),
|
||||
min_size_sqm: 0,
|
||||
max_rent_cents: max_rent_euros * 100,
|
||||
min_rooms: 0,
|
||||
required_amenities: 0,
|
||||
max_move_in_days: 365,
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter by type.
|
||||
pub fn with_type(mut self, listing_type: ListingType) -> Self {
|
||||
self.listing_types = 1 << (listing_type as u8);
|
||||
self
|
||||
}
|
||||
|
||||
/// Require minimum size.
|
||||
pub fn with_min_size(mut self, sqm: u16) -> Self {
|
||||
self.min_size_sqm = sqm;
|
||||
self
|
||||
}
|
||||
|
||||
/// Require minimum rooms.
|
||||
pub fn with_min_rooms(mut self, rooms: u8) -> Self {
|
||||
self.min_rooms = rooms;
|
||||
self
|
||||
}
|
||||
|
||||
/// Require amenities.
|
||||
pub fn with_amenities(mut self, amenities: u16) -> Self {
|
||||
self.required_amenities = amenities;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if listing matches.
|
||||
pub fn matches(&self, listing: &ListingAnnounce) -> bool {
|
||||
// Type match
|
||||
if self.listing_types != 0xFF && (self.listing_types & (1 << listing.listing_type) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Location
|
||||
if !listing.postal_prefix.starts_with(&self.postal_prefix)
|
||||
&& !self.postal_prefix.starts_with(&listing.postal_prefix)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Size
|
||||
if listing.size_sqm < self.min_size_sqm {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rent
|
||||
if listing.rent_cents > self.max_rent_cents {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rooms
|
||||
if listing.rooms < self.min_rooms {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Amenities (all required must be present)
|
||||
if listing.amenities & self.required_amenities != self.required_amenities {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Availability
|
||||
listing.available_from_days <= self.max_move_in_days
|
||||
}
|
||||
|
||||
/// Encode to CBOR.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ServiceError> {
|
||||
encode_payload(self)
|
||||
}
|
||||
|
||||
/// Decode from CBOR.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, ServiceError> {
|
||||
decode_payload(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Housing service handler.
|
||||
pub struct HousingService {
|
||||
pub is_provider: bool,
|
||||
pub is_relay: bool,
|
||||
}
|
||||
|
||||
impl HousingService {
|
||||
/// Create a new handler.
|
||||
pub fn new(is_provider: bool, is_relay: bool) -> Self {
|
||||
Self {
|
||||
is_provider,
|
||||
is_relay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a relay-only handler.
|
||||
pub fn relay() -> Self {
|
||||
Self::new(false, true)
|
||||
}
|
||||
|
||||
/// Create a provider handler.
|
||||
pub fn provider() -> Self {
|
||||
Self::new(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceHandler for HousingService {
|
||||
fn service_id(&self) -> u32 {
|
||||
HOUSING
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"Housing"
|
||||
}
|
||||
|
||||
fn handle(
|
||||
&self,
|
||||
message: &ServiceMessage,
|
||||
context: &HandlerContext,
|
||||
) -> Result<ServiceAction, ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => {
|
||||
let _listing = ListingAnnounce::from_bytes(&message.payload)?;
|
||||
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::StoreAndForward)
|
||||
} else {
|
||||
Ok(ServiceAction::Store)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Query => {
|
||||
let query = ListingQuery::from_bytes(&message.payload)?;
|
||||
|
||||
let _matches: Vec<_> = context
|
||||
.store
|
||||
.by_service(HOUSING)
|
||||
.into_iter()
|
||||
.filter(|stored| {
|
||||
if stored.message.message_type != MessageType::Announce {
|
||||
return false;
|
||||
}
|
||||
if let Ok(listing) = ListingAnnounce::from_bytes(&stored.message.payload) {
|
||||
query.matches(&listing)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::ForwardOnly)
|
||||
} else {
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Reserve | MessageType::Confirm | MessageType::Cancel => {
|
||||
if self.is_relay {
|
||||
Ok(ServiceAction::ForwardOnly)
|
||||
} else {
|
||||
Ok(ServiceAction::Handled)
|
||||
}
|
||||
}
|
||||
|
||||
MessageType::Revoke => Ok(ServiceAction::Handled),
|
||||
|
||||
_ => Ok(ServiceAction::Drop),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self, message: &ServiceMessage) -> Result<(), ServiceError> {
|
||||
match message.message_type {
|
||||
MessageType::Announce => {
|
||||
ListingAnnounce::from_bytes(&message.payload)?;
|
||||
}
|
||||
MessageType::Query => {
|
||||
ListingQuery::from_bytes(&message.payload)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_query(&self, listing: &StoredMessage, query_msg: &ServiceMessage) -> bool {
|
||||
let Ok(listing_data) = ListingAnnounce::from_bytes(&listing.message.payload) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(query) = ListingQuery::from_bytes(&query_msg.payload) else {
|
||||
return false;
|
||||
};
|
||||
query.matches(&listing_data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a housing announce.
|
||||
pub fn create_announce(
|
||||
identity: &crate::ServiceIdentity,
|
||||
listing: &ListingAnnounce,
|
||||
sequence: u64,
|
||||
) -> Result<ServiceMessage, ServiceError> {
|
||||
let payload = listing.to_bytes()?;
|
||||
Ok(ServiceMessage::announce(identity, HOUSING, payload, sequence))
|
||||
}
|
||||
|
||||
/// Helper to create a housing query.
|
||||
pub fn create_query(
|
||||
identity: &crate::ServiceIdentity,
|
||||
query: &ListingQuery,
|
||||
) -> Result<ServiceMessage, ServiceError> {
|
||||
let payload = query.to_bytes()?;
|
||||
Ok(ServiceMessage::query(identity, HOUSING, payload))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::ServiceIdentity;
|
||||
|
||||
#[test]
|
||||
fn listing_roundtrip() {
|
||||
let listing = ListingAnnounce::new(ListingType::Apartment, 65, 850, "104")
|
||||
.with_rooms(2)
|
||||
.with_amenities(amenities::FURNISHED | amenities::BALCONY)
|
||||
.with_title("Cozy 2-room in Kreuzberg");
|
||||
|
||||
let bytes = listing.to_bytes().unwrap();
|
||||
let decoded = ListingAnnounce::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert_eq!(decoded.size_sqm, 65);
|
||||
assert_eq!(decoded.rent_euros(), 850);
|
||||
assert_eq!(decoded.rooms, 2);
|
||||
assert!(decoded.has_amenity(amenities::FURNISHED));
|
||||
assert!(decoded.has_amenity(amenities::BALCONY));
|
||||
assert!(!decoded.has_amenity(amenities::PARKING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_matches() {
|
||||
let listing = ListingAnnounce::new(ListingType::Apartment, 50, 700, "104")
|
||||
.with_rooms(2)
|
||||
.with_amenities(amenities::FURNISHED);
|
||||
|
||||
// Basic match
|
||||
let query = ListingQuery::new("104", 800);
|
||||
assert!(query.matches(&listing));
|
||||
|
||||
// Too expensive for query
|
||||
let cheap_query = ListingQuery::new("104", 500);
|
||||
assert!(!cheap_query.matches(&listing));
|
||||
|
||||
// Wrong location
|
||||
let wrong_loc = ListingQuery::new("200", 800);
|
||||
assert!(!wrong_loc.matches(&listing));
|
||||
|
||||
// Size requirement
|
||||
let big_query = ListingQuery::new("104", 800).with_min_size(60);
|
||||
assert!(!big_query.matches(&listing));
|
||||
|
||||
// Amenity requirement
|
||||
let needs_parking = ListingQuery::new("104", 800).with_amenities(amenities::PARKING);
|
||||
assert!(!needs_parking.matches(&listing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_message_helpers() {
|
||||
let id = ServiceIdentity::generate();
|
||||
|
||||
let listing = ListingAnnounce::new(ListingType::Room, 20, 400, "100");
|
||||
let msg = create_announce(&id, &listing, 1).unwrap();
|
||||
assert_eq!(msg.service_id, HOUSING);
|
||||
assert_eq!(msg.message_type, MessageType::Announce);
|
||||
|
||||
let query = ListingQuery::new("100", 500);
|
||||
let msg = create_query(&id, &query).unwrap();
|
||||
assert_eq!(msg.service_id, HOUSING);
|
||||
assert_eq!(msg.message_type, MessageType::Query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn housing_handler_processes_listing() {
|
||||
use crate::capabilities;
|
||||
use crate::router::ServiceRouter;
|
||||
|
||||
let mut router = ServiceRouter::new(capabilities::RELAY);
|
||||
router.register(Box::new(HousingService::relay()));
|
||||
|
||||
let id = ServiceIdentity::generate();
|
||||
let listing = ListingAnnounce::new(ListingType::SharedFlat, 15, 350, "100");
|
||||
let msg = create_announce(&id, &listing, 1).unwrap();
|
||||
|
||||
let action = router.handle(msg, Some(id.public_key())).unwrap();
|
||||
assert!(matches!(action, ServiceAction::StoreAndForward));
|
||||
assert_eq!(router.store().service_count(HOUSING), 1);
|
||||
}
|
||||
}
|
||||
4
crates/meshservice/src/services/mod.rs
Normal file
4
crates/meshservice/src/services/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! Built-in service implementations.
|
||||
|
||||
pub mod fapp;
|
||||
pub mod housing;
|
||||
406
crates/meshservice/src/store.rs
Normal file
406
crates/meshservice/src/store.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
//! In-memory message store with eviction policies.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::message::ServiceMessage;
|
||||
|
||||
/// Configuration for the message store.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoreConfig {
|
||||
/// Maximum messages per service.
|
||||
pub max_per_service: usize,
|
||||
/// Maximum messages per sender (per service).
|
||||
pub max_per_sender: usize,
|
||||
/// Maximum total messages.
|
||||
pub max_total: usize,
|
||||
/// Prune interval in seconds.
|
||||
pub prune_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for StoreConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_per_service: 10_000,
|
||||
max_per_sender: 100,
|
||||
max_total: 50_000,
|
||||
prune_interval_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored message with metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredMessage {
|
||||
pub message: ServiceMessage,
|
||||
/// Sender's public key (needed for verification).
|
||||
pub sender_public_key: [u8; 32],
|
||||
/// When we stored this message.
|
||||
pub stored_at: u64,
|
||||
}
|
||||
|
||||
/// Generic service message store.
|
||||
///
|
||||
/// Organized by service_id, then by sender_address, then by message_id.
|
||||
pub struct ServiceStore {
|
||||
config: StoreConfig,
|
||||
/// service_id -> sender_address -> message_id -> StoredMessage
|
||||
messages: HashMap<u32, HashMap<[u8; 16], HashMap<[u8; 16], StoredMessage>>>,
|
||||
/// Total message count.
|
||||
total_count: usize,
|
||||
/// Last prune timestamp.
|
||||
last_prune: u64,
|
||||
}
|
||||
|
||||
impl ServiceStore {
|
||||
/// Create a new store with default config.
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(StoreConfig::default())
|
||||
}
|
||||
|
||||
/// Create with custom config.
|
||||
pub fn with_config(config: StoreConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
messages: HashMap::new(),
|
||||
total_count: 0,
|
||||
last_prune: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a message, returning true if it was new.
|
||||
pub fn store(&mut self, message: ServiceMessage, sender_public_key: [u8; 32]) -> bool {
|
||||
// Prune if interval passed
|
||||
self.maybe_prune();
|
||||
|
||||
let service_id = message.service_id;
|
||||
let sender_address = message.sender_address;
|
||||
let message_id = message.id;
|
||||
|
||||
// Check per-service limit and evict if needed
|
||||
{
|
||||
let service_count: usize = self.messages
|
||||
.get(&service_id)
|
||||
.map(|s| s.values().map(|m| m.len()).sum())
|
||||
.unwrap_or(0);
|
||||
if service_count >= self.config.max_per_service {
|
||||
self.evict_oldest_in_service(service_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check per-sender limit and evict if needed
|
||||
{
|
||||
let sender_count = self.messages
|
||||
.get(&service_id)
|
||||
.and_then(|s| s.get(&sender_address))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
if sender_count >= self.config.max_per_sender {
|
||||
self.evict_oldest_from_sender(service_id, sender_address);
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create maps
|
||||
let service_map = self.messages.entry(service_id).or_default();
|
||||
let sender_map = service_map.entry(sender_address).or_default();
|
||||
|
||||
// Check for existing message
|
||||
let is_new_or_update = if let Some(existing) = sender_map.get(&message_id) {
|
||||
// Existing: only update if higher sequence
|
||||
if message.sequence <= existing.message.sequence {
|
||||
return false;
|
||||
}
|
||||
// This is an update, not a new message
|
||||
false
|
||||
} else {
|
||||
// New message
|
||||
true
|
||||
};
|
||||
|
||||
let stored_at = now();
|
||||
sender_map.insert(
|
||||
message_id,
|
||||
StoredMessage {
|
||||
message,
|
||||
sender_public_key,
|
||||
stored_at,
|
||||
},
|
||||
);
|
||||
|
||||
if is_new_or_update {
|
||||
self.total_count += 1;
|
||||
}
|
||||
|
||||
// Return true for both new messages and updates
|
||||
true
|
||||
}
|
||||
|
||||
/// Get a message by service, sender, and ID.
|
||||
pub fn get(
|
||||
&self,
|
||||
service_id: u32,
|
||||
sender_address: &[u8; 16],
|
||||
message_id: &[u8; 16],
|
||||
) -> Option<&StoredMessage> {
|
||||
self.messages
|
||||
.get(&service_id)?
|
||||
.get(sender_address)?
|
||||
.get(message_id)
|
||||
}
|
||||
|
||||
/// Get all messages from a sender in a service.
|
||||
pub fn by_sender(&self, service_id: u32, sender_address: &[u8; 16]) -> Vec<&StoredMessage> {
|
||||
self.messages
|
||||
.get(&service_id)
|
||||
.and_then(|s| s.get(sender_address))
|
||||
.map(|m| m.values().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all messages in a service.
|
||||
pub fn by_service(&self, service_id: u32) -> Vec<&StoredMessage> {
|
||||
self.messages
|
||||
.get(&service_id)
|
||||
.map(|s| s.values().flat_map(|m| m.values()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Query messages with a predicate.
|
||||
pub fn query<F>(&self, service_id: u32, predicate: F) -> Vec<&StoredMessage>
|
||||
where
|
||||
F: Fn(&StoredMessage) -> bool,
|
||||
{
|
||||
self.by_service(service_id)
|
||||
.into_iter()
|
||||
.filter(|m| predicate(m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove a specific message.
|
||||
pub fn remove(
|
||||
&mut self,
|
||||
service_id: u32,
|
||||
sender_address: &[u8; 16],
|
||||
message_id: &[u8; 16],
|
||||
) -> Option<StoredMessage> {
|
||||
let result = self
|
||||
.messages
|
||||
.get_mut(&service_id)?
|
||||
.get_mut(sender_address)?
|
||||
.remove(message_id);
|
||||
|
||||
if result.is_some() {
|
||||
self.total_count = self.total_count.saturating_sub(1);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove all messages from a sender.
|
||||
pub fn remove_sender(&mut self, service_id: u32, sender_address: &[u8; 16]) -> usize {
|
||||
let count = self
|
||||
.messages
|
||||
.get_mut(&service_id)
|
||||
.and_then(|s| s.remove(sender_address))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
self.total_count = self.total_count.saturating_sub(count);
|
||||
count
|
||||
}
|
||||
|
||||
/// Prune expired messages.
|
||||
pub fn prune_expired(&mut self) -> usize {
|
||||
let now = now();
|
||||
let mut removed = 0;
|
||||
|
||||
for service_map in self.messages.values_mut() {
|
||||
for sender_map in service_map.values_mut() {
|
||||
let expired: Vec<[u8; 16]> = sender_map
|
||||
.iter()
|
||||
.filter(|(_, m)| m.message.is_expired())
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in expired {
|
||||
sender_map.remove(&id);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.total_count = self.total_count.saturating_sub(removed);
|
||||
self.last_prune = now;
|
||||
removed
|
||||
}
|
||||
|
||||
/// Get total message count.
|
||||
pub fn len(&self) -> usize {
|
||||
self.total_count
|
||||
}
|
||||
|
||||
/// Check if empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.total_count == 0
|
||||
}
|
||||
|
||||
/// Get count by service.
|
||||
pub fn service_count(&self, service_id: u32) -> usize {
|
||||
self.messages
|
||||
.get(&service_id)
|
||||
.map(|s| s.values().map(|m| m.len()).sum())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Run prune if interval passed.
|
||||
fn maybe_prune(&mut self) {
|
||||
let now = now();
|
||||
if now.saturating_sub(self.last_prune) >= self.config.prune_interval_secs {
|
||||
self.prune_expired();
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict oldest message in a service.
|
||||
fn evict_oldest_in_service(&mut self, service_id: u32) {
|
||||
let Some(service_map) = self.messages.get_mut(&service_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut oldest: Option<([u8; 16], [u8; 16], u64)> = None;
|
||||
|
||||
for (sender, msgs) in service_map.iter() {
|
||||
for (id, stored) in msgs.iter() {
|
||||
match oldest {
|
||||
Some((_, _, ts)) if stored.message.timestamp < ts => {
|
||||
oldest = Some((*sender, *id, stored.message.timestamp));
|
||||
}
|
||||
None => {
|
||||
oldest = Some((*sender, *id, stored.message.timestamp));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((sender, id, _)) = oldest {
|
||||
if let Some(sender_map) = service_map.get_mut(&sender) {
|
||||
sender_map.remove(&id);
|
||||
self.total_count = self.total_count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict oldest message from a sender.
|
||||
fn evict_oldest_from_sender(&mut self, service_id: u32, sender_address: [u8; 16]) {
|
||||
let Some(sender_map) = self
|
||||
.messages
|
||||
.get_mut(&service_id)
|
||||
.and_then(|s| s.get_mut(&sender_address))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let oldest = sender_map
|
||||
.iter()
|
||||
.min_by_key(|(_, m)| m.message.timestamp)
|
||||
.map(|(id, _)| *id);
|
||||
|
||||
if let Some(id) = oldest {
|
||||
sender_map.remove(&id);
|
||||
self.total_count = self.total_count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServiceStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{identity::ServiceIdentity, message::ServiceMessage, service_ids::FAPP};
|
||||
|
||||
fn make_message(id: &ServiceIdentity, seq: u64) -> ServiceMessage {
|
||||
ServiceMessage::announce(id, FAPP, b"test".to_vec(), seq)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_retrieve() {
|
||||
let mut store = ServiceStore::new();
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = make_message(&id, 1);
|
||||
|
||||
assert!(store.store(msg.clone(), id.public_key()));
|
||||
assert_eq!(store.len(), 1);
|
||||
|
||||
let retrieved = store.get(FAPP, &id.address(), &msg.id);
|
||||
assert!(retrieved.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rejected() {
|
||||
let mut store = ServiceStore::new();
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = make_message(&id, 1);
|
||||
|
||||
assert!(store.store(msg.clone(), id.public_key()));
|
||||
assert!(!store.store(msg.clone(), id.public_key())); // Duplicate
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_sequence_updates() {
|
||||
let mut store = ServiceStore::new();
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg1 = make_message(&id, 1);
|
||||
let mut msg2 = make_message(&id, 2);
|
||||
msg2.id = msg1.id; // Same ID
|
||||
|
||||
store.store(msg1.clone(), id.public_key());
|
||||
assert!(store.store(msg2.clone(), id.public_key())); // Updates
|
||||
|
||||
let retrieved = store.get(FAPP, &id.address(), &msg1.id).unwrap();
|
||||
assert_eq!(retrieved.message.sequence, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_by_sender() {
|
||||
let mut store = ServiceStore::new();
|
||||
let id1 = ServiceIdentity::generate();
|
||||
let id2 = ServiceIdentity::generate();
|
||||
|
||||
store.store(make_message(&id1, 1), id1.public_key());
|
||||
store.store(make_message(&id1, 2), id1.public_key());
|
||||
store.store(make_message(&id2, 1), id2.public_key());
|
||||
|
||||
let sender1_msgs = store.by_sender(FAPP, &id1.address());
|
||||
assert_eq!(sender1_msgs.len(), 2);
|
||||
|
||||
let sender2_msgs = store.by_sender(FAPP, &id2.address());
|
||||
assert_eq!(sender2_msgs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sender() {
|
||||
let mut store = ServiceStore::new();
|
||||
let id = ServiceIdentity::generate();
|
||||
|
||||
store.store(make_message(&id, 1), id.public_key());
|
||||
store.store(make_message(&id, 2), id.public_key());
|
||||
assert_eq!(store.len(), 2);
|
||||
|
||||
let removed = store.remove_sender(FAPP, &id.address());
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
}
|
||||
290
crates/meshservice/src/verification.rs
Normal file
290
crates/meshservice/src/verification.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! Verification framework for building trust in decentralized services.
|
||||
//!
|
||||
//! Verification levels:
|
||||
//! - 0: None (bare announce)
|
||||
//! - 1: Self-asserted (profile URL, metadata)
|
||||
//! - 2: Endorsed by trusted peers
|
||||
//! - 3: Registry-verified (KBV for therapists, trade registry for craftsmen)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::identity::ServiceIdentity;
|
||||
|
||||
/// Verification levels (higher = more trusted).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum VerificationLevel {
|
||||
#[default]
|
||||
None = 0,
|
||||
SelfAsserted = 1,
|
||||
PeerEndorsed = 2,
|
||||
RegistryVerified = 3,
|
||||
}
|
||||
|
||||
impl From<u8> for VerificationLevel {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
1 => VerificationLevel::SelfAsserted,
|
||||
2 => VerificationLevel::PeerEndorsed,
|
||||
3.. => VerificationLevel::RegistryVerified,
|
||||
_ => VerificationLevel::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A verification attestation attached to a service message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Verification {
|
||||
/// Verification level.
|
||||
pub level: u8,
|
||||
/// Verifier's mesh address.
|
||||
pub verifier_address: [u8; 16],
|
||||
/// What is being verified (e.g., "license", "identity").
|
||||
pub claim: String,
|
||||
/// Optional external reference (URL, registry ID).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<String>,
|
||||
/// Signature over (level || sender_address || claim).
|
||||
pub signature: Vec<u8>,
|
||||
/// Timestamp of verification.
|
||||
pub timestamp: u64,
|
||||
/// Optional expiry timestamp.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
impl Verification {
|
||||
/// Create a new peer endorsement.
|
||||
pub fn peer_endorsement(
|
||||
verifier: &ServiceIdentity,
|
||||
subject_address: &[u8; 16],
|
||||
claim: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
verifier,
|
||||
VerificationLevel::PeerEndorsed,
|
||||
subject_address,
|
||||
claim,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a registry verification.
|
||||
pub fn registry(
|
||||
verifier: &ServiceIdentity,
|
||||
subject_address: &[u8; 16],
|
||||
claim: impl Into<String>,
|
||||
reference: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
verifier,
|
||||
VerificationLevel::RegistryVerified,
|
||||
subject_address,
|
||||
claim,
|
||||
Some(reference.into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new verification.
|
||||
pub fn new(
|
||||
verifier: &ServiceIdentity,
|
||||
level: VerificationLevel,
|
||||
subject_address: &[u8; 16],
|
||||
claim: impl Into<String>,
|
||||
reference: Option<String>,
|
||||
) -> Self {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let claim = claim.into();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let signable = Self::signable_bytes(level as u8, subject_address, &claim);
|
||||
let signature = verifier.sign(&signable).to_vec();
|
||||
|
||||
Self {
|
||||
level: level as u8,
|
||||
verifier_address: verifier.address(),
|
||||
claim,
|
||||
reference,
|
||||
signature,
|
||||
timestamp,
|
||||
expires: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set expiry time.
|
||||
pub fn with_expiry(mut self, expires: u64) -> Self {
|
||||
self.expires = Some(expires);
|
||||
self
|
||||
}
|
||||
|
||||
/// Create signable bytes.
|
||||
fn signable_bytes(level: u8, subject_address: &[u8; 16], claim: &str) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(17 + claim.len());
|
||||
buf.push(level);
|
||||
buf.extend_from_slice(subject_address);
|
||||
buf.extend_from_slice(claim.as_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Verify this attestation.
|
||||
pub fn verify(&self, verifier_public_key: &[u8; 32], subject_address: &[u8; 16]) -> bool {
|
||||
use crate::identity::compute_address;
|
||||
|
||||
// Verify verifier address matches key
|
||||
if compute_address(verifier_public_key) != self.verifier_address {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if let Some(expires) = self.expires {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
if now > expires {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let sig: [u8; 64] = match self.signature.as_slice().try_into() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let signable = Self::signable_bytes(self.level, subject_address, &self.claim);
|
||||
ServiceIdentity::verify(verifier_public_key, &signable, &sig)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set of known trusted verifiers (registries, endorsers).
|
||||
#[derive(Default)]
|
||||
pub struct TrustedVerifiers {
|
||||
/// Known public keys with their trust level.
|
||||
verifiers: Vec<TrustedVerifier>,
|
||||
}
|
||||
|
||||
/// A trusted verifier entry.
|
||||
#[derive(Clone)]
|
||||
pub struct TrustedVerifier {
|
||||
pub public_key: [u8; 32],
|
||||
pub address: [u8; 16],
|
||||
pub name: String,
|
||||
pub max_level: VerificationLevel,
|
||||
}
|
||||
|
||||
impl TrustedVerifiers {
|
||||
/// Create empty set.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a trusted verifier.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
public_key: [u8; 32],
|
||||
name: impl Into<String>,
|
||||
max_level: VerificationLevel,
|
||||
) {
|
||||
use crate::identity::compute_address;
|
||||
|
||||
self.verifiers.push(TrustedVerifier {
|
||||
public_key,
|
||||
address: compute_address(&public_key),
|
||||
name: name.into(),
|
||||
max_level,
|
||||
});
|
||||
}
|
||||
|
||||
/// Find a verifier by address.
|
||||
pub fn find_by_address(&self, address: &[u8; 16]) -> Option<&TrustedVerifier> {
|
||||
self.verifiers.iter().find(|v| &v.address == address)
|
||||
}
|
||||
|
||||
/// Verify a verification against known trusted verifiers.
|
||||
/// Returns the effective level (or 0 if not trusted).
|
||||
pub fn check(&self, verification: &Verification, subject_address: &[u8; 16]) -> u8 {
|
||||
let Some(verifier) = self.find_by_address(&verification.verifier_address) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Level cannot exceed verifier's max
|
||||
let claimed_level = verification.level.min(verifier.max_level as u8);
|
||||
|
||||
// Actually verify the signature
|
||||
if verification.verify(&verifier.public_key, subject_address) {
|
||||
claimed_level
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the highest trusted verification level from a list.
|
||||
pub fn highest_level(
|
||||
&self,
|
||||
verifications: &[Verification],
|
||||
subject_address: &[u8; 16],
|
||||
) -> VerificationLevel {
|
||||
verifications
|
||||
.iter()
|
||||
.map(|v| self.check(v, subject_address))
|
||||
.max()
|
||||
.map(VerificationLevel::from)
|
||||
.unwrap_or(VerificationLevel::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_endorsement_roundtrip() {
|
||||
let verifier = ServiceIdentity::generate();
|
||||
let subject_address = [1u8; 16];
|
||||
|
||||
let v = Verification::peer_endorsement(&verifier, &subject_address, "good_actor");
|
||||
assert!(v.verify(&verifier.public_key(), &subject_address));
|
||||
assert_eq!(v.level, VerificationLevel::PeerEndorsed as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_verifiers_check() {
|
||||
let verifier = ServiceIdentity::generate();
|
||||
let subject_address = [2u8; 16];
|
||||
|
||||
let mut trusted = TrustedVerifiers::new();
|
||||
trusted.add(verifier.public_key(), "Test Registry", VerificationLevel::RegistryVerified);
|
||||
|
||||
let v = Verification::registry(&verifier, &subject_address, "licensed", "REG-12345");
|
||||
let level = trusted.check(&v, &subject_address);
|
||||
assert_eq!(level, VerificationLevel::RegistryVerified as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_verifier_returns_zero() {
|
||||
let verifier = ServiceIdentity::generate();
|
||||
let subject_address = [3u8; 16];
|
||||
|
||||
let trusted = TrustedVerifiers::new(); // Empty
|
||||
|
||||
let v = Verification::registry(&verifier, &subject_address, "licensed", "REG-999");
|
||||
let level = trusted.check(&v, &subject_address);
|
||||
assert_eq!(level, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_verification_fails() {
|
||||
let verifier = ServiceIdentity::generate();
|
||||
let subject_address = [4u8; 16];
|
||||
|
||||
let v = Verification::peer_endorsement(&verifier, &subject_address, "trusted")
|
||||
.with_expiry(1); // Expired in 1970
|
||||
|
||||
assert!(!v.verify(&verifier.public_key(), &subject_address));
|
||||
}
|
||||
}
|
||||
259
crates/meshservice/src/wire.rs
Normal file
259
crates/meshservice/src/wire.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! Wire format for service messages.
|
||||
//!
|
||||
//! Binary format for efficient network transmission.
|
||||
//! Uses CBOR for payload encoding.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use crate::error::ServiceError;
|
||||
use crate::message::{MessageType, ServiceMessage};
|
||||
|
||||
/// Wire message header (fixed 64 bytes).
|
||||
///
|
||||
/// ```text
|
||||
/// ┌─────────────────────────────────────────────────────┐
|
||||
/// │ 0-3 │ service_id (u32 LE) │
|
||||
/// │ 4 │ message_type (u8) │
|
||||
/// │ 5 │ version (u8) │
|
||||
/// │ 6-7 │ flags (u16 LE, reserved) │
|
||||
/// │ 8-23 │ message_id (16 bytes) │
|
||||
/// │ 24-39 │ sender_address (16 bytes) │
|
||||
/// │ 40-47 │ sequence (u64 LE) │
|
||||
/// │ 48-49 │ ttl_hours (u16 LE) │
|
||||
/// │ 50-57 │ timestamp (u64 LE) │
|
||||
/// │ 58 │ hop_count (u8) │
|
||||
/// │ 59 │ max_hops (u8) │
|
||||
/// │ 60-63 │ payload_len (u32 LE) │
|
||||
/// └─────────────────────────────────────────────────────┘
|
||||
/// Followed by:
|
||||
/// │ 64-... │ signature (64 bytes) │
|
||||
/// │ signature_end-.. │ payload (payload_len bytes) │
|
||||
/// │ payload_end-.. │ verifications (CBOR, optional) │
|
||||
/// ```
|
||||
|
||||
const HEADER_SIZE: usize = 64;
|
||||
const SIGNATURE_SIZE: usize = 64;
|
||||
|
||||
/// Encode a ServiceMessage to bytes.
|
||||
pub fn encode(msg: &ServiceMessage) -> Result<Vec<u8>, ServiceError> {
|
||||
let verifications_bytes = if msg.verifications.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(&msg.verifications, &mut buf)?;
|
||||
buf
|
||||
};
|
||||
|
||||
let total_size = HEADER_SIZE + SIGNATURE_SIZE + msg.payload.len() + verifications_bytes.len();
|
||||
let mut buf = Vec::with_capacity(total_size);
|
||||
|
||||
// Header
|
||||
buf.extend_from_slice(&msg.service_id.to_le_bytes()); // 0-3
|
||||
buf.push(msg.message_type as u8); // 4
|
||||
buf.push(msg.version); // 5
|
||||
buf.extend_from_slice(&0u16.to_le_bytes()); // 6-7 flags (reserved)
|
||||
buf.extend_from_slice(&msg.id); // 8-23
|
||||
buf.extend_from_slice(&msg.sender_address); // 24-39
|
||||
buf.extend_from_slice(&msg.sequence.to_le_bytes()); // 40-47
|
||||
buf.extend_from_slice(&msg.ttl_hours.to_le_bytes()); // 48-49
|
||||
buf.extend_from_slice(&msg.timestamp.to_le_bytes()); // 50-57
|
||||
buf.push(msg.hop_count); // 58
|
||||
buf.push(msg.max_hops); // 59
|
||||
buf.extend_from_slice(&(msg.payload.len() as u32).to_le_bytes()); // 60-63
|
||||
|
||||
// Signature
|
||||
if msg.signature.len() != SIGNATURE_SIZE {
|
||||
return Err(ServiceError::InvalidFormat(format!(
|
||||
"signature must be {} bytes, got {}",
|
||||
SIGNATURE_SIZE,
|
||||
msg.signature.len()
|
||||
)));
|
||||
}
|
||||
buf.extend_from_slice(&msg.signature);
|
||||
|
||||
// Payload
|
||||
buf.extend_from_slice(&msg.payload);
|
||||
|
||||
// Verifications (optional)
|
||||
buf.extend_from_slice(&verifications_bytes);
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Decode bytes to a ServiceMessage.
|
||||
pub fn decode(data: &[u8]) -> Result<ServiceMessage, ServiceError> {
|
||||
if data.len() < HEADER_SIZE + SIGNATURE_SIZE {
|
||||
return Err(ServiceError::InvalidFormat("message too short".into()));
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(data);
|
||||
let mut buf4 = [0u8; 4];
|
||||
let mut buf8 = [0u8; 8];
|
||||
let mut buf16 = [0u8; 16];
|
||||
let mut buf2 = [0u8; 2];
|
||||
|
||||
// Read header
|
||||
cursor.read_exact(&mut buf4)?;
|
||||
let service_id = u32::from_le_bytes(buf4);
|
||||
|
||||
let mut type_byte = [0u8; 1];
|
||||
cursor.read_exact(&mut type_byte)?;
|
||||
let message_type = MessageType::try_from(type_byte[0])
|
||||
.map_err(|_| ServiceError::InvalidFormat("invalid message type".into()))?;
|
||||
|
||||
cursor.read_exact(&mut type_byte)?;
|
||||
let version = type_byte[0];
|
||||
|
||||
cursor.read_exact(&mut buf2)?; // flags (ignored)
|
||||
|
||||
cursor.read_exact(&mut buf16)?;
|
||||
let id = buf16;
|
||||
|
||||
cursor.read_exact(&mut buf16)?;
|
||||
let sender_address = buf16;
|
||||
|
||||
cursor.read_exact(&mut buf8)?;
|
||||
let sequence = u64::from_le_bytes(buf8);
|
||||
|
||||
cursor.read_exact(&mut buf2)?;
|
||||
let ttl_hours = u16::from_le_bytes(buf2);
|
||||
|
||||
cursor.read_exact(&mut buf8)?;
|
||||
let timestamp = u64::from_le_bytes(buf8);
|
||||
|
||||
cursor.read_exact(&mut type_byte)?;
|
||||
let hop_count = type_byte[0];
|
||||
|
||||
cursor.read_exact(&mut type_byte)?;
|
||||
let max_hops = type_byte[0];
|
||||
|
||||
cursor.read_exact(&mut buf4)?;
|
||||
let payload_len = u32::from_le_bytes(buf4) as usize;
|
||||
|
||||
// Read signature
|
||||
let mut signature = vec![0u8; SIGNATURE_SIZE];
|
||||
cursor.read_exact(&mut signature)?;
|
||||
|
||||
// Read payload
|
||||
if data.len() < HEADER_SIZE + SIGNATURE_SIZE + payload_len {
|
||||
return Err(ServiceError::InvalidFormat("payload truncated".into()));
|
||||
}
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
cursor.read_exact(&mut payload)?;
|
||||
|
||||
// Read verifications (remaining bytes)
|
||||
let verifications = if cursor.position() < data.len() as u64 {
|
||||
let mut remaining = Vec::new();
|
||||
cursor.read_to_end(&mut remaining)?;
|
||||
if remaining.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
ciborium::from_reader(&remaining[..])
|
||||
.map_err(|e| ServiceError::Serialization(e.to_string()))?
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(ServiceMessage {
|
||||
service_id,
|
||||
message_type,
|
||||
version,
|
||||
id,
|
||||
sender_address,
|
||||
payload,
|
||||
signature,
|
||||
verifications,
|
||||
sequence,
|
||||
ttl_hours,
|
||||
timestamp,
|
||||
hop_count,
|
||||
max_hops,
|
||||
})
|
||||
}
|
||||
|
||||
// Implement std::io::Error conversion for Read trait
|
||||
impl From<std::io::Error> for ServiceError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
ServiceError::InvalidFormat(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a payload struct to CBOR.
|
||||
pub fn encode_payload<T: serde::Serialize>(payload: &T) -> Result<Vec<u8>, ServiceError> {
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(payload, &mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Decode a payload from CBOR.
|
||||
pub fn decode_payload<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<T, ServiceError> {
|
||||
ciborium::from_reader(data).map_err(|e| ServiceError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::ServiceIdentity;
|
||||
use crate::service_ids::FAPP;
|
||||
use crate::verification::Verification;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_simple() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let msg = ServiceMessage::announce(&id, FAPP, b"hello world".to_vec(), 42);
|
||||
|
||||
let encoded = encode(&msg).unwrap();
|
||||
let decoded = decode(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.service_id, FAPP);
|
||||
assert_eq!(decoded.message_type, MessageType::Announce);
|
||||
assert_eq!(decoded.sequence, 42);
|
||||
assert_eq!(decoded.payload, b"hello world");
|
||||
assert_eq!(decoded.signature, msg.signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_verifications() {
|
||||
let id = ServiceIdentity::generate();
|
||||
let verifier = ServiceIdentity::generate();
|
||||
|
||||
let mut msg = ServiceMessage::announce(&id, FAPP, b"payload".to_vec(), 1);
|
||||
msg.add_verification(Verification::peer_endorsement(
|
||||
&verifier,
|
||||
&id.address(),
|
||||
"trusted",
|
||||
));
|
||||
|
||||
let encoded = encode(&msg).unwrap();
|
||||
let decoded = decode(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.verifications.len(), 1);
|
||||
assert_eq!(decoded.verifications[0].claim, "trusted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_codec() {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||
struct TestPayload {
|
||||
name: String,
|
||||
value: i32,
|
||||
}
|
||||
|
||||
let payload = TestPayload {
|
||||
name: "test".into(),
|
||||
value: 123,
|
||||
};
|
||||
|
||||
let encoded = encode_payload(&payload).unwrap();
|
||||
let decoded: TestPayload = decode_payload(&encoded).unwrap();
|
||||
|
||||
assert_eq!(payload, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_rejected() {
|
||||
let result = decode(&[0u8; 10]);
|
||||
assert!(matches!(result, Err(ServiceError::InvalidFormat(_))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user