diff --git a/Cargo.lock b/Cargo.lock index d9567c6..f2425b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,7 @@ dependencies = [ "objc2-foundation", "rand 0.8.5", "rns-crypto", + "rns-identity", "rns-transport", "rns-wire", "serde", diff --git a/crates/lxmf-core/src/peer.rs b/crates/lxmf-core/src/peer.rs index ace8471..dd9ab54 100644 --- a/crates/lxmf-core/src/peer.rs +++ b/crates/lxmf-core/src/peer.rs @@ -130,10 +130,13 @@ impl LxmPeer { peer } - /// Effective minimum stamp cost this peer will accept. + /// Effective minimum stamp cost this peer will accept, using the peer's + /// announced flexibility when known (Python `LXMPeer.sync`: cost - flex). pub fn minimum_accepted_stamp_cost(&self) -> u8 { match self.stamp_cost { - Some(cost) => cost.saturating_sub(PROPAGATION_COST_FLEX), + Some(cost) => { + cost.saturating_sub(self.stamp_cost_flexibility.unwrap_or(PROPAGATION_COST_FLEX)) + } None => 0, } } @@ -465,6 +468,13 @@ mod tests { // cost < flex must saturate at 0. peer.stamp_cost = Some(2); assert_eq!(peer.minimum_accepted_stamp_cost(), 0); + + // Announced flexibility overrides the default. + peer.stamp_cost = Some(16); + peer.stamp_cost_flexibility = Some(5); + assert_eq!(peer.minimum_accepted_stamp_cost(), 11); + peer.stamp_cost_flexibility = Some(0); + assert_eq!(peer.minimum_accepted_stamp_cost(), 16); } /// T0-4: an absurd announce-supplied peering cost must fail the bounded diff --git a/crates/lxmf-core/src/router.rs b/crates/lxmf-core/src/router.rs index 3d85182..f735f63 100644 --- a/crates/lxmf-core/src/router.rs +++ b/crates/lxmf-core/src/router.rs @@ -699,9 +699,9 @@ impl LxmRouter { .add(Ticket::new(token, destination_hash, expires)); } - /// Returns the token of the most-recently-added valid ticket for `destination_hash`. + /// Returns the token of the first valid ticket for `destination_hash`. /// - /// Python reference: `LXMRouter.get_outbound_ticket` — LXMRouter.py:1115-1123. + /// Python reference: `LXMRouter.get_outbound_ticket` — LXMRouter.py:1058-1064. pub fn get_outbound_ticket(&self, destination_hash: &[u8; 16]) -> Option<[u8; 16]> { let now = now_f64(); self.ticket_store @@ -903,6 +903,11 @@ impl LxmRouter { .replace_locally_delivered(persist::load_local_deliveries(state_dir)?); self.propagation_store .replace_locally_processed(persist::load_locally_processed(state_dir)?); + // Python cleans tickets and stamp costs at load (LXMRouter.py:258-284). + let now = now_f64(); + self.ticket_store.cull(now); + self.outbound_stamp_costs + .retain(|_, e| now - e.recorded_at < STAMP_COST_EXPIRY as f64); Ok(()) } @@ -1173,7 +1178,10 @@ impl LxmRouter { destination_hash: &[u8; 16], ) -> bool { let now = now_f64(); - if let Some(ticket) = self.ticket_store.find(destination_hash, now) { + for ticket in self.ticket_store.all() { + if &ticket.destination_hash != destination_hash || !ticket.is_valid(now) { + continue; + } let mut material = Vec::with_capacity(16 + 32); material.extend_from_slice(&ticket.token); material.extend_from_slice(message_id); @@ -2440,6 +2448,24 @@ mod tests { assert_eq!(router.get_inbound_tickets().len(), 2); } + #[test] + fn test_validate_stamp_checks_all_tickets() { + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + let expires = now_f64() + 1000.0; + router.remember_ticket(dest, [0x01; 16], expires); + router.remember_ticket(dest, [0x02; 16], expires); + + // Stamp derived from the SECOND ticket must still validate. + let message_id = [0x33u8; 32]; + let mut material = Vec::with_capacity(16 + 32); + material.extend_from_slice(&[0x02; 16]); + material.extend_from_slice(&message_id); + let stamp = rns_crypto::sha::truncated_hash(&material); + + assert!(router.validate_stamp_with_tickets(&message_id, stamp.as_ref(), 16, &dest)); + } + #[test] fn test_cancel_outbound() { let mut router = LxmRouter::new(RouterConfig::default()); diff --git a/crates/lxmf-core/src/ticket.rs b/crates/lxmf-core/src/ticket.rs index 3d63b06..b865558 100644 --- a/crates/lxmf-core/src/ticket.rs +++ b/crates/lxmf-core/src/ticket.rs @@ -1,7 +1,8 @@ //! LXMF Ticket system: bypass PoW with pre-shared 16-byte tokens. //! //! Trusted peers may exchange tickets that bypass stamp requirements for a -//! fixed expiry window. Tickets are single-use and renewable before expiry. +//! fixed expiry window. Tickets are reusable until expiry and renewed once +//! within `TICKET_RENEW` of expiring. use serde::{Deserialize, Serialize}; @@ -33,10 +34,6 @@ impl Ticket { pub fn should_renew(&self, now: f64) -> bool { self.is_valid(now) && (self.expires - now) < TICKET_RENEW as f64 } - - pub fn use_ticket(&mut self) { - self.used = true; - } } #[derive(Debug, Default)] @@ -59,17 +56,6 @@ impl TicketStore { .find(|t| &t.destination_hash == destination_hash && t.is_valid(now)) } - /// Find and mark a ticket as used. Returns the token on success. - pub fn use_for(&mut self, destination_hash: &[u8; 16], now: f64) -> Option<[u8; 16]> { - for ticket in &mut self.tickets { - if &ticket.destination_hash == destination_hash && ticket.is_valid(now) { - ticket.use_ticket(); - return Some(ticket.token); - } - } - None - } - /// Drop expired and used tickets (past TICKET_GRACE). pub fn cull(&mut self, now: f64) { self.tickets @@ -104,9 +90,11 @@ mod tests { #[test] fn test_ticket_used() { + // `used` survives only for persisted-state compat; is_valid must + // still reject such entries. let mut ticket = Ticket::new([0xAA; 16], [0xBB; 16], 1000.0); assert!(ticket.is_valid(500.0)); - ticket.use_ticket(); + ticket.used = true; assert!(!ticket.is_valid(500.0)); } @@ -135,20 +123,6 @@ mod tests { assert!(found.is_some()); } - #[test] - fn test_ticket_store_use() { - let mut store = TicketStore::new(); - let dest = [0xBB; 16]; - - store.add(Ticket::new([0x01; 16], dest, 1000.0)); - - let token = store.use_for(&dest, 500.0); - assert_eq!(token, Some([0x01; 16])); - - let token = store.use_for(&dest, 500.0); - assert!(token.is_none()); - } - #[test] fn test_ticket_store_cull() { let mut store = TicketStore::new();